Compare commits
	
		
			63 Commits
		
	
	
		
			outposts/s
			...
			enterprise
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 74deedc9d4 | |||
| 3c8232b9a5 | |||
| 4af415f3fd | |||
| ef82143811 | |||
| c7567e031a | |||
| 3b2cd9e8d6 | |||
| 261e18b3d6 | |||
| 51a0f7d314 | |||
| 041ffef812 | |||
| 68b4d58ebd | |||
| 881571bd14 | |||
| 64a0f66e62 | |||
| 7d5cda4c25 | |||
| 8ba2679036 | |||
| d98523f243 | |||
| 6da0548fa2 | |||
| 8734710e61 | |||
| 64b996aa1f | |||
| dbe91cbc55 | |||
| a56e037eae | |||
| b8f1e2fac0 | |||
| e1b56aac05 | |||
| 794731eed7 | |||
| 19fbc2a022 | |||
| 38e467bf8e | |||
| 9e32cf361b | |||
| 42a5a43640 | |||
| 8d5b835c4f | |||
| ca3b948895 | |||
| a714c781a6 | |||
| df2e3878d5 | |||
| 1370c32aea | |||
| 0ae373bc1e | |||
| 6facb5872e | |||
| c67de17dd8 | |||
| 2128e7f45f | |||
| 0e7a4849f6 | |||
| 85343fa5d4 | |||
| 12f16241fb | |||
| 2c3a040e35 | |||
| ec0dd8c6a0 | |||
| 7b8c27ad2c | |||
| 79b80c2ed2 | |||
| 28485e8a15 | |||
| e86b4514bc | |||
| 179f5c7acf | |||
| e7538b85e1 | |||
| ab8f5a2ac4 | |||
| 67c22c1313 | |||
| 74e090239a | |||
| e5f0fc6469 | |||
| 945987f10f | |||
| 4ba360e7af | |||
| a8fd0c376f | |||
| 0e5d647238 | |||
| 306f227813 | |||
| e89e592061 | |||
| 454bf554a6 | |||
| eab6ca96a7 | |||
| 7746d2ab7a | |||
| 4fe38172e3 | |||
| e6082e0f08 | |||
| 9402c19966 | 
							
								
								
									
										2
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							| @ -35,7 +35,7 @@ runs: | ||||
|       run: | | ||||
|         export PSQL_TAG=${{ inputs.postgresql_version }} | ||||
|         docker compose -f .github/actions/setup/docker-compose.yml up -d | ||||
|         poetry install --sync | ||||
|         poetry sync | ||||
|         cd web && npm ci | ||||
|     - name: Generate config | ||||
|       shell: poetry run python {0} | ||||
|  | ||||
							
								
								
									
										2
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								Makefile
									
									
									
									
									
								
							| @ -21,7 +21,7 @@ pg_name := $(shell python -m authentik.lib.config postgresql.name 2>/dev/null) | ||||
| CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \ | ||||
| 		-I .github/codespell-words.txt \ | ||||
| 		-S 'web/src/locales/**' \ | ||||
| 		-S 'website/developer-docs/api/reference/**' \ | ||||
| 		-S 'website/docs/developer-docs/api/reference/**' \ | ||||
| 		-S '**/node_modules/**' \ | ||||
| 		-S '**/dist/**' \ | ||||
| 		$(PY_SOURCES) \ | ||||
|  | ||||
| @ -50,7 +50,6 @@ from authentik.enterprise.providers.microsoft_entra.models import ( | ||||
|     MicrosoftEntraProviderGroup, | ||||
|     MicrosoftEntraProviderUser, | ||||
| ) | ||||
| from authentik.enterprise.providers.rac.models import ConnectionToken | ||||
| from authentik.enterprise.providers.ssf.models import StreamEvent | ||||
| from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import ( | ||||
|     EndpointDevice, | ||||
| @ -72,6 +71,7 @@ from authentik.providers.oauth2.models import ( | ||||
|     DeviceToken, | ||||
|     RefreshToken, | ||||
| ) | ||||
| from authentik.providers.rac.models import ConnectionToken | ||||
| from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser | ||||
| from authentik.rbac.models import Role | ||||
| from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser | ||||
|  | ||||
| @ -4,6 +4,7 @@ from json import loads | ||||
|  | ||||
| from django.db.models import Prefetch | ||||
| from django.http import Http404 | ||||
| from django.utils.translation import gettext as _ | ||||
| from django_filters.filters import CharFilter, ModelMultipleChoiceFilter | ||||
| from django_filters.filterset import FilterSet | ||||
| from drf_spectacular.utils import ( | ||||
| @ -81,9 +82,37 @@ class GroupSerializer(ModelSerializer): | ||||
|         if not self.instance or not parent: | ||||
|             return parent | ||||
|         if str(parent.group_uuid) == str(self.instance.group_uuid): | ||||
|             raise ValidationError("Cannot set group as parent of itself.") | ||||
|             raise ValidationError(_("Cannot set group as parent of itself.")) | ||||
|         return parent | ||||
|  | ||||
|     def validate_is_superuser(self, superuser: bool): | ||||
|         """Ensure that the user creating this group has permissions to set the superuser flag""" | ||||
|         request: Request = self.context.get("request", None) | ||||
|         if not request: | ||||
|             return superuser | ||||
|         # If we're updating an instance, and the state hasn't changed, we don't need to check perms | ||||
|         if self.instance and superuser == self.instance.is_superuser: | ||||
|             return superuser | ||||
|         user: User = request.user | ||||
|         perm = ( | ||||
|             "authentik_core.enable_group_superuser" | ||||
|             if superuser | ||||
|             else "authentik_core.disable_group_superuser" | ||||
|         ) | ||||
|         has_perm = user.has_perm(perm) | ||||
|         if self.instance and not has_perm: | ||||
|             has_perm = user.has_perm(perm, self.instance) | ||||
|         if not has_perm: | ||||
|             raise ValidationError( | ||||
|                 _( | ||||
|                     ( | ||||
|                         "User does not have permission to set " | ||||
|                         "superuser status to {superuser_status}." | ||||
|                     ).format_map({"superuser_status": superuser}) | ||||
|                 ) | ||||
|             ) | ||||
|         return superuser | ||||
|  | ||||
|     class Meta: | ||||
|         model = Group | ||||
|         fields = [ | ||||
|  | ||||
							
								
								
									
										26
									
								
								authentik/core/migrations/0043_alter_group_options.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								authentik/core/migrations/0043_alter_group_options.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,26 @@ | ||||
| # Generated by Django 5.0.11 on 2025-01-30 23:55 | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_core", "0042_authenticatedsession_authentik_c_expires_08251d_idx_and_more"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterModelOptions( | ||||
|             name="group", | ||||
|             options={ | ||||
|                 "permissions": [ | ||||
|                     ("add_user_to_group", "Add user to group"), | ||||
|                     ("remove_user_from_group", "Remove user from group"), | ||||
|                     ("enable_group_superuser", "Enable superuser status"), | ||||
|                     ("disable_group_superuser", "Disable superuser status"), | ||||
|                 ], | ||||
|                 "verbose_name": "Group", | ||||
|                 "verbose_name_plural": "Groups", | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
| @ -204,6 +204,8 @@ class Group(SerializerModel, AttributesMixin): | ||||
|         permissions = [ | ||||
|             ("add_user_to_group", _("Add user to group")), | ||||
|             ("remove_user_from_group", _("Remove user from group")), | ||||
|             ("enable_group_superuser", _("Enable superuser status")), | ||||
|             ("disable_group_superuser", _("Disable superuser status")), | ||||
|         ] | ||||
|  | ||||
|     def __str__(self): | ||||
|  | ||||
| @ -35,8 +35,7 @@ from authentik.flows.planner import ( | ||||
|     FlowPlanner, | ||||
| ) | ||||
| from authentik.flows.stage import StageView | ||||
| from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET | ||||
| from authentik.lib.views import bad_request_message | ||||
| from authentik.policies.denied import AccessDeniedResponse | ||||
| from authentik.policies.utils import delete_none_values | ||||
| @ -47,8 +46,9 @@ from authentik.stages.user_write.stage import PLAN_CONTEXT_USER_PATH | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| SESSION_KEY_OVERRIDE_FLOW_TOKEN = "authentik/flows/source_override_flow_token"  # nosec | ||||
| PLAN_CONTEXT_SOURCE_GROUPS = "source_groups" | ||||
| SESSION_KEY_SOURCE_FLOW_STAGES = "authentik/flows/source_flow_stages" | ||||
| SESSION_KEY_OVERRIDE_FLOW_TOKEN = "authentik/flows/source_override_flow_token"  # nosec | ||||
|  | ||||
|  | ||||
| class MessageStage(StageView): | ||||
| @ -219,28 +219,28 @@ class SourceFlowManager: | ||||
|             } | ||||
|         ) | ||||
|         flow_context.update(self.policy_context) | ||||
|         if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session: | ||||
|             token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN) | ||||
|             self._logger.info("Replacing source flow with overridden flow", flow=token.flow.slug) | ||||
|             plan = token.plan | ||||
|             plan.context[PLAN_CONTEXT_IS_RESTORED] = token | ||||
|             plan.context.update(flow_context) | ||||
|             for stage in self.get_stages_to_append(flow): | ||||
|                 plan.append_stage(stage) | ||||
|             if stages: | ||||
|                 for stage in stages: | ||||
|                     plan.append_stage(stage) | ||||
|             self.request.session[SESSION_KEY_PLAN] = plan | ||||
|             flow_slug = token.flow.slug | ||||
|             token.delete() | ||||
|             return redirect_with_qs( | ||||
|                 "authentik_core:if-flow", | ||||
|                 self.request.GET, | ||||
|                 flow_slug=flow_slug, | ||||
|             ) | ||||
|         flow_context.setdefault(PLAN_CONTEXT_REDIRECT, final_redirect) | ||||
|  | ||||
|         if not flow: | ||||
|             # We only check for the flow token here if we don't have a flow, otherwise we rely on | ||||
|             # SESSION_KEY_SOURCE_FLOW_STAGES to delegate the usage of this token and dynamically add | ||||
|             # stages that deal with this token to return to another flow | ||||
|             if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session: | ||||
|                 token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN) | ||||
|                 self._logger.info( | ||||
|                     "Replacing source flow with overridden flow", flow=token.flow.slug | ||||
|                 ) | ||||
|                 plan = token.plan | ||||
|                 plan.context[PLAN_CONTEXT_IS_RESTORED] = token | ||||
|                 plan.context.update(flow_context) | ||||
|                 for stage in self.get_stages_to_append(flow): | ||||
|                     plan.append_stage(stage) | ||||
|                 if stages: | ||||
|                     for stage in stages: | ||||
|                         plan.append_stage(stage) | ||||
|                 redirect = plan.to_redirect(self.request, token.flow) | ||||
|                 token.delete() | ||||
|                 return redirect | ||||
|             return bad_request_message( | ||||
|                 self.request, | ||||
|                 _("Configured flow does not exist."), | ||||
| @ -259,6 +259,8 @@ class SourceFlowManager: | ||||
|         if stages: | ||||
|             for stage in stages: | ||||
|                 plan.append_stage(stage) | ||||
|         for stage in self.request.session.get(SESSION_KEY_SOURCE_FLOW_STAGES, []): | ||||
|             plan.append_stage(stage) | ||||
|         return plan.to_redirect(self.request, flow) | ||||
|  | ||||
|     def handle_auth( | ||||
| @ -295,6 +297,8 @@ class SourceFlowManager: | ||||
|         # When request isn't authenticated we jump straight to auth | ||||
|         if not self.request.user.is_authenticated: | ||||
|             return self.handle_auth(connection) | ||||
|         # When an override flow token exists we actually still use a flow for link | ||||
|         # to continue the existing flow we came from | ||||
|         if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session: | ||||
|             return self._prepare_flow(None, connection) | ||||
|         connection.save() | ||||
|  | ||||
| @ -67,6 +67,8 @@ def clean_expired_models(self: SystemTask): | ||||
|                 raise ImproperlyConfigured( | ||||
|                     "Invalid session_storage setting, allowed values are db and cache" | ||||
|                 ) | ||||
|     if CONFIG.get("session_storage", "cache") == "db": | ||||
|         DBSessionStore.clear_expired() | ||||
|     LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount) | ||||
|  | ||||
|     messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}") | ||||
|  | ||||
| @ -4,7 +4,7 @@ from django.urls.base import reverse | ||||
| from guardian.shortcuts import assign_perm | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
| from authentik.core.models import Group, User | ||||
| from authentik.core.models import Group | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_user | ||||
| from authentik.lib.generators import generate_id | ||||
|  | ||||
| @ -14,7 +14,7 @@ class TestGroupsAPI(APITestCase): | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         self.login_user = create_test_user() | ||||
|         self.user = User.objects.create(username="test-user") | ||||
|         self.user = create_test_user() | ||||
|  | ||||
|     def test_list_with_users(self): | ||||
|         """Test listing with users""" | ||||
| @ -109,3 +109,57 @@ class TestGroupsAPI(APITestCase): | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 400) | ||||
|  | ||||
|     def test_superuser_no_perm(self): | ||||
|         """Test creating a superuser group without permission""" | ||||
|         assign_perm("authentik_core.add_group", self.login_user) | ||||
|         self.client.force_login(self.login_user) | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_api:group-list"), | ||||
|             data={"name": generate_id(), "is_superuser": True}, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 400) | ||||
|         self.assertJSONEqual( | ||||
|             res.content, | ||||
|             {"is_superuser": ["User does not have permission to set superuser status to True."]}, | ||||
|         ) | ||||
|  | ||||
|     def test_superuser_update_no_perm(self): | ||||
|         """Test updating a superuser group without permission""" | ||||
|         group = Group.objects.create(name=generate_id(), is_superuser=True) | ||||
|         assign_perm("view_group", self.login_user, group) | ||||
|         assign_perm("change_group", self.login_user, group) | ||||
|         self.client.force_login(self.login_user) | ||||
|         res = self.client.patch( | ||||
|             reverse("authentik_api:group-detail", kwargs={"pk": group.pk}), | ||||
|             data={"is_superuser": False}, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 400) | ||||
|         self.assertJSONEqual( | ||||
|             res.content, | ||||
|             {"is_superuser": ["User does not have permission to set superuser status to False."]}, | ||||
|         ) | ||||
|  | ||||
|     def test_superuser_update_no_change(self): | ||||
|         """Test updating a superuser group without permission | ||||
|         and without changing the superuser status""" | ||||
|         group = Group.objects.create(name=generate_id(), is_superuser=True) | ||||
|         assign_perm("view_group", self.login_user, group) | ||||
|         assign_perm("change_group", self.login_user, group) | ||||
|         self.client.force_login(self.login_user) | ||||
|         res = self.client.patch( | ||||
|             reverse("authentik_api:group-detail", kwargs={"pk": group.pk}), | ||||
|             data={"name": generate_id(), "is_superuser": True}, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 200) | ||||
|  | ||||
|     def test_superuser_create(self): | ||||
|         """Test creating a superuser group with permission""" | ||||
|         assign_perm("authentik_core.add_group", self.login_user) | ||||
|         assign_perm("authentik_core.enable_group_superuser", self.login_user) | ||||
|         self.client.force_login(self.login_user) | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_api:group-list"), | ||||
|             data={"name": generate_id(), "is_superuser": True}, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 201) | ||||
|  | ||||
| @ -97,6 +97,8 @@ class EnterpriseAuditMiddleware(AuditMiddleware): | ||||
|         thread_kwargs: dict | None = None, | ||||
|         **_, | ||||
|     ): | ||||
|         if not self.enabled: | ||||
|             return super().post_save_handler(request, sender, instance, created, thread_kwargs, **_) | ||||
|         if not should_log_model(instance): | ||||
|             return None | ||||
|         thread_kwargs = {} | ||||
| @ -122,6 +124,8 @@ class EnterpriseAuditMiddleware(AuditMiddleware): | ||||
|     ): | ||||
|         thread_kwargs = {} | ||||
|         m2m_field = None | ||||
|         if not self.enabled: | ||||
|             return super().m2m_changed_handler(request, sender, instance, action, thread_kwargs) | ||||
|         # For the audit log we don't care about `pre_` or `post_` so we trim that part off | ||||
|         _, _, action_direction = action.partition("_") | ||||
|         # resolve the "through" model to an actual field | ||||
|  | ||||
| @ -1,14 +0,0 @@ | ||||
| """RAC app config""" | ||||
|  | ||||
| from authentik.enterprise.apps import EnterpriseConfig | ||||
|  | ||||
|  | ||||
| class AuthentikEnterpriseProviderRAC(EnterpriseConfig): | ||||
|     """authentik enterprise rac app config""" | ||||
|  | ||||
|     name = "authentik.enterprise.providers.rac" | ||||
|     label = "authentik_providers_rac" | ||||
|     verbose_name = "authentik Enterprise.Providers.RAC" | ||||
|     default = True | ||||
|     mountpoint = "" | ||||
|     ws_mountpoint = "authentik.enterprise.providers.rac.urls" | ||||
							
								
								
									
										12
									
								
								authentik/enterprise/reporting/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								authentik/enterprise/reporting/apps.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| """Reporting app config""" | ||||
|  | ||||
| from authentik.enterprise.apps import EnterpriseConfig | ||||
|  | ||||
|  | ||||
| class AuthentikEnterpriseReporting(EnterpriseConfig): | ||||
|     """authentik enterprise reporting app config""" | ||||
|  | ||||
|     name = "authentik.enterprise.reporting" | ||||
|     label = "authentik_reporting" | ||||
|     verbose_name = "authentik Enterprise.Reporting" | ||||
|     default = True | ||||
							
								
								
									
										22
									
								
								authentik/enterprise/reporting/executor.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								authentik/enterprise/reporting/executor.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,22 @@ | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.enterprise.reporting.models import Report | ||||
|  | ||||
|  | ||||
| class ReportExecutor: | ||||
|     """Execute a report""" | ||||
|  | ||||
|     def __init__(self, report: Report) -> None: | ||||
|         self.report = report | ||||
|         self.logger = get_logger().bind(report=self.report) | ||||
|  | ||||
|     def execute(self): | ||||
|         # 1. Run through policies bound to report itself | ||||
|         # 2. Get all bound components by running through ReportComponentBinding, | ||||
|         #   while evaluating policies bound to each | ||||
|         # 3. render the actual components | ||||
|         # 4. Store the final data...somewhere?? | ||||
|         # 5. Optionally render PDF via chromedriver (special frontend that uses API) | ||||
|         #   (not required for MVP) | ||||
|         # 6. Send out link to CSV/PDF or attach to email via delivery | ||||
|         pass | ||||
							
								
								
									
										131
									
								
								authentik/enterprise/reporting/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								authentik/enterprise/reporting/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,131 @@ | ||||
| # Generated by Django 5.0.4 on 2024-04-18 21:47 | ||||
|  | ||||
| import authentik.lib.models | ||||
| import django.db.models.deletion | ||||
| import uuid | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     initial = True | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_events", "0007_event_authentik_e_action_9a9dd9_idx_and_more"), | ||||
|         ("authentik_policies", "0011_policybinding_failure_result_and_more"), | ||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="ReportComponent", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "widget_uuid", | ||||
|                     models.UUIDField( | ||||
|                         default=uuid.uuid4, editable=False, primary_key=True, serialize=False | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "Report Component", | ||||
|                 "verbose_name_plural": "Report Components", | ||||
|             }, | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name="Report", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "policybindingmodel_ptr", | ||||
|                     models.OneToOneField( | ||||
|                         auto_created=True, | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         parent_link=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         to="authentik_policies.policybindingmodel", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("name", models.TextField()), | ||||
|                 ("schedule", models.TextField()), | ||||
|                 ("output_type", models.TextField(choices=[("csv", "Csv"), ("pdf", "Pdf")])), | ||||
|                 ( | ||||
|                     "delivery", | ||||
|                     models.ForeignKey( | ||||
|                         default=None, | ||||
|                         null=True, | ||||
|                         on_delete=django.db.models.deletion.SET_DEFAULT, | ||||
|                         to="authentik_events.notificationtransport", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "run_as", | ||||
|                     models.ForeignKey( | ||||
|                         default=None, | ||||
|                         null=True, | ||||
|                         on_delete=django.db.models.deletion.SET_DEFAULT, | ||||
|                         to=settings.AUTH_USER_MODEL, | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "Report", | ||||
|                 "verbose_name_plural": "Reports", | ||||
|             }, | ||||
|             bases=("authentik_policies.policybindingmodel", models.Model), | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name="ReportComponentBinding", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "policybindingmodel_ptr", | ||||
|                     models.OneToOneField( | ||||
|                         auto_created=True, | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         parent_link=True, | ||||
|                         to="authentik_policies.policybindingmodel", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "binding_uuid", | ||||
|                     models.UUIDField( | ||||
|                         default=uuid.uuid4, editable=False, primary_key=True, serialize=False | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("enabled", models.BooleanField(default=True)), | ||||
|                 ("layout_x", models.PositiveIntegerField(default=0)), | ||||
|                 ("layout_y", models.PositiveIntegerField(default=0)), | ||||
|                 ( | ||||
|                     "target", | ||||
|                     models.ForeignKey( | ||||
|                         on_delete=django.db.models.deletion.CASCADE, to="authentik_reporting.report" | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "widget", | ||||
|                     authentik.lib.models.InheritanceForeignKey( | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         related_name="+", | ||||
|                         to="authentik_reporting.reportcomponent", | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "Report Component Binding", | ||||
|                 "verbose_name_plural": "Report Component Bindings", | ||||
|                 "unique_together": {("target", "widget")}, | ||||
|             }, | ||||
|             bases=("authentik_policies.policybindingmodel", models.Model), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="report", | ||||
|             name="components", | ||||
|             field=models.ManyToManyField( | ||||
|                 blank=True, | ||||
|                 related_name="bindings", | ||||
|                 through="authentik_reporting.ReportComponentBinding", | ||||
|                 to="authentik_reporting.reportcomponent", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										87
									
								
								authentik/enterprise/reporting/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								authentik/enterprise/reporting/models.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,87 @@ | ||||
| """Reporting models""" | ||||
|  | ||||
| from uuid import uuid4 | ||||
|  | ||||
| from celery.schedules import crontab | ||||
| from django.db import models | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| from authentik.events.models import NotificationTransport | ||||
| from authentik.lib.models import InheritanceForeignKey, SerializerModel | ||||
| from authentik.policies.models import PolicyBindingModel | ||||
|  | ||||
|  | ||||
| class OutputType(models.TextChoices): | ||||
|     """Different choices in which a report can be 'rendered'""" | ||||
|  | ||||
|     csv = "csv" | ||||
|     pdf = "pdf" | ||||
|  | ||||
|  | ||||
| class Report(SerializerModel, PolicyBindingModel): | ||||
|     """A report with a defined list of components, which can run on a schedule""" | ||||
|  | ||||
|     name = models.TextField() | ||||
|  | ||||
|     schedule = models.TextField() | ||||
|  | ||||
|     # User under which permissions the queries are run, | ||||
|     # when no user is selected the report is inactive | ||||
|     run_as = models.ForeignKey( | ||||
|         "authentik_core.user", on_delete=models.SET_DEFAULT, default=None, null=True | ||||
|     ) | ||||
|     components = models.ManyToManyField( | ||||
|         "ReportComponent", through="ReportComponentBinding", related_name="bindings", blank=True | ||||
|     ) | ||||
|     output_type = models.TextField(choices=OutputType.choices) | ||||
|     # Use notification transport to send report result (either link for webhook based? | ||||
|     # maybe send full csv?) or fully rendered PDF via Email | ||||
|     # when no transport is selected, reports are not sent anywhere but can be retrieved in authentik | ||||
|     delivery = models.ForeignKey( | ||||
|         NotificationTransport, on_delete=models.SET_DEFAULT, default=None, null=True | ||||
|     ) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return self.name | ||||
|  | ||||
|     def get_celery_schedule(self) -> crontab: | ||||
|         return crontab(*self.schedule.split()) | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("Report") | ||||
|         verbose_name_plural = _("Reports") | ||||
|  | ||||
|  | ||||
| class ReportComponentBinding(SerializerModel, PolicyBindingModel): | ||||
|     """Binding of a component to a report""" | ||||
|  | ||||
|     binding_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||
|  | ||||
|     enabled = models.BooleanField(default=True) | ||||
|  | ||||
|     layout_x = models.PositiveIntegerField(default=0) | ||||
|     layout_y = models.PositiveIntegerField(default=0) | ||||
|  | ||||
|     target = models.ForeignKey("Report", on_delete=models.CASCADE) | ||||
|     widget = InheritanceForeignKey("ReportComponent", on_delete=models.CASCADE, related_name="+") | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return f"Binding from {self.report.name} to {self.widget}" | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("Report Component Binding") | ||||
|         verbose_name_plural = _("Report Component Bindings") | ||||
|         unique_together = ("target", "widget") | ||||
|  | ||||
|  | ||||
| class ReportComponent(SerializerModel): | ||||
|     """An individual component of a report, a query or graph, etc""" | ||||
|  | ||||
|     widget_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return super().__str__() | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("Report Component") | ||||
|         verbose_name_plural = _("Report Components") | ||||
							
								
								
									
										38
									
								
								authentik/enterprise/reporting/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								authentik/enterprise/reporting/signals.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,38 @@ | ||||
| from json import dumps | ||||
|  | ||||
| from django.db.models.signals import post_save, pre_delete | ||||
| from django.dispatch import receiver | ||||
| from django_celery_beat.models import CrontabSchedule, PeriodicTask | ||||
|  | ||||
| from authentik.enterprise.reporting.models import Report | ||||
|  | ||||
|  | ||||
| @receiver(post_save, sender=Report) | ||||
| def report_post_save(sender, instance: Report, **_): | ||||
|     if instance.schedule == "": | ||||
|         return | ||||
|     schedule = CrontabSchedule.from_schedule(instance.get_celery_schedule()) | ||||
|     schedule.save() | ||||
|     PeriodicTask.objects.update_or_create( | ||||
|         name=str(instance.pk), | ||||
|         defaults={ | ||||
|             "crontab": schedule, | ||||
|             "task": "authentik.enterprise.reporting.tasks.process_report", | ||||
|             "queue": "authentik_reporting", | ||||
|             "description": f"Report {instance.name}", | ||||
|             "kwargs": dumps( | ||||
|                 { | ||||
|                     "report_uuid": str(instance.pk), | ||||
|                 } | ||||
|             ), | ||||
|         }, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @receiver(pre_delete, sender=Report) | ||||
| def report_pre_delete(sender, instance: Report, **_): | ||||
|     if instance.schedule == "": | ||||
|         return | ||||
|     PeriodicTask.objects.filter(name=str(instance.pk)).delete() | ||||
|     # Cleanup schedules without any tasks | ||||
|     CrontabSchedule.objects.filter(periodictask__isnull=True).delete() | ||||
							
								
								
									
										11
									
								
								authentik/enterprise/reporting/tasks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								authentik/enterprise/reporting/tasks.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | ||||
| from authentik.enterprise.reporting.executor import ReportExecutor | ||||
| from authentik.enterprise.reporting.models import Report | ||||
| from authentik.root.celery import CELERY_APP | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task() | ||||
| def process_report(report_uuid: str): | ||||
|     report = Report.objects.filter(pk=report_uuid).first() | ||||
|     if not report or not report.run_as: | ||||
|         return | ||||
|     ReportExecutor(report).execute() | ||||
| @ -16,8 +16,8 @@ TENANT_APPS = [ | ||||
|     "authentik.enterprise.audit", | ||||
|     "authentik.enterprise.providers.google_workspace", | ||||
|     "authentik.enterprise.providers.microsoft_entra", | ||||
|     "authentik.enterprise.providers.rac", | ||||
|     "authentik.enterprise.providers.ssf", | ||||
|     "authentik.enterprise.reporting", | ||||
|     "authentik.enterprise.stages.authenticator_endpoint_gdtc", | ||||
|     "authentik.enterprise.stages.source", | ||||
| ] | ||||
|  | ||||
| @ -9,13 +9,16 @@ from django.utils.timezone import now | ||||
| from guardian.shortcuts import get_anonymous_user | ||||
|  | ||||
| from authentik.core.models import Source, User | ||||
| from authentik.core.sources.flow_manager import SESSION_KEY_OVERRIDE_FLOW_TOKEN | ||||
| from authentik.core.sources.flow_manager import ( | ||||
|     SESSION_KEY_OVERRIDE_FLOW_TOKEN, | ||||
|     SESSION_KEY_SOURCE_FLOW_STAGES, | ||||
| ) | ||||
| from authentik.core.types import UILoginButton | ||||
| from authentik.enterprise.stages.source.models import SourceStage | ||||
| from authentik.flows.challenge import Challenge, ChallengeResponse | ||||
| from authentik.flows.models import FlowToken | ||||
| from authentik.flows.models import FlowToken, in_memory_stage | ||||
| from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED | ||||
| from authentik.flows.stage import ChallengeStageView | ||||
| from authentik.flows.stage import ChallengeStageView, StageView | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
|  | ||||
| PLAN_CONTEXT_RESUME_TOKEN = "resume_token"  # nosec | ||||
| @ -49,6 +52,7 @@ class SourceStageView(ChallengeStageView): | ||||
|     def get_challenge(self, *args, **kwargs) -> Challenge: | ||||
|         resume_token = self.create_flow_token() | ||||
|         self.request.session[SESSION_KEY_OVERRIDE_FLOW_TOKEN] = resume_token | ||||
|         self.request.session[SESSION_KEY_SOURCE_FLOW_STAGES] = [in_memory_stage(SourceStageFinal)] | ||||
|         return self.login_button.challenge | ||||
|  | ||||
|     def create_flow_token(self) -> FlowToken: | ||||
| @ -77,3 +81,19 @@ class SourceStageView(ChallengeStageView): | ||||
|  | ||||
|     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: | ||||
|         return self.executor.stage_ok() | ||||
|  | ||||
|  | ||||
| class SourceStageFinal(StageView): | ||||
|     """Dynamic stage injected in the source flow manager. This is injected in the | ||||
|     flow the source flow manager picks (authentication or enrollment), and will run at the end. | ||||
|     This stage uses the override flow token to resume execution of the initial flow the | ||||
|     source stage is bound to.""" | ||||
|  | ||||
|     def dispatch(self): | ||||
|         token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN) | ||||
|         self._logger.info("Replacing source flow with overridden flow", flow=token.flow.slug) | ||||
|         plan = token.plan | ||||
|         plan.context[PLAN_CONTEXT_IS_RESTORED] = token | ||||
|         response = plan.to_redirect(self.request, token.flow) | ||||
|         token.delete() | ||||
|         return response | ||||
|  | ||||
| @ -64,6 +64,8 @@ debugger: false | ||||
| log_level: info | ||||
|  | ||||
| session_storage: cache | ||||
| sessions: | ||||
|   unauthenticated_age: days=1 | ||||
|  | ||||
| error_reporting: | ||||
|   enabled: false | ||||
|  | ||||
							
								
								
									
										54
									
								
								authentik/lib/utils/email.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								authentik/lib/utils/email.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,54 @@ | ||||
| """Email utility functions""" | ||||
|  | ||||
|  | ||||
| def mask_email(email: str | None) -> str | None: | ||||
|     """Mask email address for privacy | ||||
|  | ||||
|     Args: | ||||
|         email: Email address to mask | ||||
|     Returns: | ||||
|         Masked email address or None if input is None | ||||
|     Example: | ||||
|         mask_email("myname@company.org") | ||||
|         'm*****@c******.org' | ||||
|     """ | ||||
|     if not email: | ||||
|         return None | ||||
|  | ||||
|     # Basic email format validation | ||||
|     if email.count("@") != 1: | ||||
|         raise ValueError("Invalid email format: Must contain exactly one '@' symbol") | ||||
|  | ||||
|     local, domain = email.split("@") | ||||
|     if not local or not domain: | ||||
|         raise ValueError("Invalid email format: Local and domain parts cannot be empty") | ||||
|  | ||||
|     domain_parts = domain.split(".") | ||||
|     if len(domain_parts) < 2:  # noqa: PLR2004 | ||||
|         raise ValueError("Invalid email format: Domain must contain at least one dot") | ||||
|  | ||||
|     limit = 2 | ||||
|  | ||||
|     # Mask local part (keep first char) | ||||
|     if len(local) <= limit: | ||||
|         masked_local = "*" * len(local) | ||||
|     else: | ||||
|         masked_local = local[0] + "*" * (len(local) - 1) | ||||
|  | ||||
|     # Mask each domain part except the last one (TLD) | ||||
|     masked_domain_parts = [] | ||||
|     for _i, part in enumerate(domain_parts[:-1]):  # Process all parts except TLD | ||||
|         if not part:  # Check for empty parts (consecutive dots) | ||||
|             raise ValueError("Invalid email format: Domain parts cannot be empty") | ||||
|         if len(part) <= limit: | ||||
|             masked_part = "*" * len(part) | ||||
|         else: | ||||
|             masked_part = part[0] + "*" * (len(part) - 1) | ||||
|         masked_domain_parts.append(masked_part) | ||||
|  | ||||
|     # Add TLD unchanged | ||||
|     if not domain_parts[-1]:  # Check if TLD is empty | ||||
|         raise ValueError("Invalid email format: TLD cannot be empty") | ||||
|     masked_domain_parts.append(domain_parts[-1]) | ||||
|  | ||||
|     return f"{masked_local}@{'.'.join(masked_domain_parts)}" | ||||
| @ -19,7 +19,6 @@ from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer | ||||
| from authentik.core.models import Provider | ||||
| from authentik.enterprise.license import LicenseKey | ||||
| from authentik.enterprise.providers.rac.models import RACProvider | ||||
| from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator | ||||
| from authentik.outposts.api.service_connections import ServiceConnectionSerializer | ||||
| from authentik.outposts.apps import MANAGED_OUTPOST, MANAGED_OUTPOST_NAME | ||||
| @ -31,6 +30,7 @@ from authentik.outposts.models import ( | ||||
| ) | ||||
| from authentik.providers.ldap.models import LDAPProvider | ||||
| from authentik.providers.proxy.models import ProxyProvider | ||||
| from authentik.providers.rac.models import RACProvider | ||||
| from authentik.providers.radius.models import RadiusProvider | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -128,12 +128,6 @@ class OutpostConsumer(JsonWebsocketConsumer): | ||||
|             state.args.update(msg.args) | ||||
|         elif msg.instruction == WebsocketMessageInstruction.ACK: | ||||
|             return | ||||
|         elif msg.instruction == WebsocketMessageInstruction.PROVIDER_SPECIFIC: | ||||
|             if "response_channel" not in msg.args: | ||||
|                 return | ||||
|             self.logger.debug("Posted response to channel", msg=msg) | ||||
|             async_to_sync(self.channel_layer.send)(msg.args.get("response_channel"), content) | ||||
|             return | ||||
|         GAUGE_OUTPOSTS_LAST_UPDATE.labels( | ||||
|             tenant=connection.schema_name, | ||||
|             outpost=self.outpost.name, | ||||
|  | ||||
| @ -1,86 +0,0 @@ | ||||
| from base64 import b64decode | ||||
| from dataclasses import asdict, dataclass | ||||
| from random import choice | ||||
| from typing import Any | ||||
| from uuid import uuid4 | ||||
|  | ||||
| from asgiref.sync import async_to_sync | ||||
| from channels.layers import get_channel_layer | ||||
| from channels_redis.pubsub import RedisPubSubChannelLayer | ||||
| from requests.adapters import BaseAdapter | ||||
| from requests.models import PreparedRequest, Response | ||||
| from requests.utils import CaseInsensitiveDict | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.outposts.models import Outpost | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class OutpostPreparedRequest: | ||||
|     uid: str | ||||
|     method: str | ||||
|     url: str | ||||
|     headers: dict[str, str] | ||||
|     body: Any | ||||
|     ssl_verify: bool | ||||
|     timeout: int | ||||
|  | ||||
|     @staticmethod | ||||
|     def from_requests(req: PreparedRequest) -> "OutpostPreparedRequest": | ||||
|         return OutpostPreparedRequest( | ||||
|             uid=str(uuid4()), | ||||
|             method=req.method, | ||||
|             url=req.url, | ||||
|             headers=req.headers._store, | ||||
|             body=req.body, | ||||
|             ssl_verify=True, | ||||
|             timeout=0, | ||||
|         ) | ||||
|  | ||||
|     @property | ||||
|     def response_channel(self) -> str: | ||||
|         return f"authentik_outpost_http_response_{self.uid}" | ||||
|  | ||||
|  | ||||
| class OutpostHTTPAdapter(BaseAdapter): | ||||
|     """Requests Adapter that sends HTTP requests via a specified Outpost""" | ||||
|  | ||||
|     def __init__(self, outpost: Outpost, default_timeout=10): | ||||
|         super().__init__() | ||||
|         self.__outpost = outpost | ||||
|         self.__logger = get_logger().bind() | ||||
|         self.__layer: RedisPubSubChannelLayer = get_channel_layer() | ||||
|         self.default_timeout = default_timeout | ||||
|  | ||||
|     def parse_response(self, raw_response: dict, req: PreparedRequest) -> Response: | ||||
|         res = Response() | ||||
|         res.request = req | ||||
|         res.status_code = raw_response.get("status") | ||||
|         res.url = raw_response.get("final_url") | ||||
|         res.headers = CaseInsensitiveDict(raw_response.get("headers")) | ||||
|         res._content = b64decode(raw_response.get("body")) | ||||
|         return res | ||||
|  | ||||
|     def send(self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None): | ||||
|         # Convert request so we can send it to the outpost | ||||
|         converted = OutpostPreparedRequest.from_requests(request) | ||||
|         converted.ssl_verify = verify | ||||
|         converted.timeout = timeout if timeout else self.default_timeout | ||||
|         # Pick one of the outpost instances | ||||
|         state = choice(self.__outpost.state)  # nosec | ||||
|         self.__logger.debug("sending HTTP request to outpost", uid=converted.uid) | ||||
|         async_to_sync(self.__layer.send)( | ||||
|             state.uid, | ||||
|             { | ||||
|                 "type": "event.provider.specific", | ||||
|                 "sub_type": "http_request", | ||||
|                 "response_channel": converted.response_channel, | ||||
|                 "request": asdict(converted), | ||||
|             }, | ||||
|         ) | ||||
|         self.__logger.debug("receiving HTTP response from outpost", uid=converted.uid) | ||||
|         raw_response = async_to_sync(self.__layer.receive)( | ||||
|             converted.response_channel, | ||||
|         ) | ||||
|         self.__logger.debug("received HTTP response from outpost", uid=converted.uid) | ||||
|         return self.parse_response(raw_response.get("args", {}).get("response", {}), request) | ||||
| @ -98,7 +98,6 @@ class OutpostType(models.TextChoices): | ||||
|     LDAP = "ldap" | ||||
|     RADIUS = "radius" | ||||
|     RAC = "rac" | ||||
|     SCIM = "scim" | ||||
|  | ||||
|  | ||||
| def default_outpost_config(host: str | None = None): | ||||
|  | ||||
| @ -18,8 +18,6 @@ from kubernetes.config.kube_config import KUBE_CONFIG_DEFAULT_LOCATION | ||||
| from structlog.stdlib import get_logger | ||||
| from yaml import safe_load | ||||
|  | ||||
| from authentik.enterprise.providers.rac.controllers.docker import RACDockerController | ||||
| from authentik.enterprise.providers.rac.controllers.kubernetes import RACKubernetesController | ||||
| from authentik.events.models import TaskStatus | ||||
| from authentik.events.system_tasks import SystemTask, prefill_task | ||||
| from authentik.lib.config import CONFIG | ||||
| @ -41,17 +39,17 @@ from authentik.providers.ldap.controllers.docker import LDAPDockerController | ||||
| from authentik.providers.ldap.controllers.kubernetes import LDAPKubernetesController | ||||
| from authentik.providers.proxy.controllers.docker import ProxyDockerController | ||||
| from authentik.providers.proxy.controllers.kubernetes import ProxyKubernetesController | ||||
| from authentik.providers.rac.controllers.docker import RACDockerController | ||||
| from authentik.providers.rac.controllers.kubernetes import RACKubernetesController | ||||
| from authentik.providers.radius.controllers.docker import RadiusDockerController | ||||
| from authentik.providers.radius.controllers.kubernetes import RadiusKubernetesController | ||||
| from authentik.providers.scim.controllers.docker import SCIMDockerController | ||||
| from authentik.providers.scim.controllers.kubernetes import SCIMKubernetesController | ||||
| from authentik.root.celery import CELERY_APP | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| CACHE_KEY_OUTPOST_DOWN = "goauthentik.io/outposts/teardown/%s" | ||||
|  | ||||
|  | ||||
| def controller_for_outpost(outpost: Outpost) -> type[BaseController] | None:  # noqa: PLR0911 | ||||
| def controller_for_outpost(outpost: Outpost) -> type[BaseController] | None: | ||||
|     """Get a controller for the outpost, when a service connection is defined""" | ||||
|     if not outpost.service_connection: | ||||
|         return None | ||||
| @ -76,11 +74,6 @@ def controller_for_outpost(outpost: Outpost) -> type[BaseController] | None:  # | ||||
|             return RACDockerController | ||||
|         if isinstance(service_connection, KubernetesServiceConnection): | ||||
|             return RACKubernetesController | ||||
|     if outpost.type == OutpostType.SCIM: | ||||
|         if isinstance(service_connection, DockerServiceConnection): | ||||
|             return SCIMDockerController | ||||
|         if isinstance(service_connection, KubernetesServiceConnection): | ||||
|             return SCIMKubernetesController | ||||
|     return None | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -42,6 +42,12 @@ class GeoIPPolicySerializer(CountryFieldMixin, PolicySerializer): | ||||
|             "asns", | ||||
|             "countries", | ||||
|             "countries_obj", | ||||
|             "check_history_distance", | ||||
|             "history_max_distance_km", | ||||
|             "distance_tolerance_km", | ||||
|             "history_login_count", | ||||
|             "check_impossible_travel", | ||||
|             "impossible_tolerance_km", | ||||
|         ] | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -0,0 +1,43 @@ | ||||
| # Generated by Django 5.0.10 on 2025-01-02 20:40 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_policies_geoip", "0001_initial"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="geoippolicy", | ||||
|             name="check_history_distance", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="geoippolicy", | ||||
|             name="check_impossible_travel", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="geoippolicy", | ||||
|             name="distance_tolerance_km", | ||||
|             field=models.PositiveIntegerField(default=50), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="geoippolicy", | ||||
|             name="history_login_count", | ||||
|             field=models.PositiveIntegerField(default=5), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="geoippolicy", | ||||
|             name="history_max_distance_km", | ||||
|             field=models.PositiveBigIntegerField(default=100), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="geoippolicy", | ||||
|             name="impossible_tolerance_km", | ||||
|             field=models.PositiveIntegerField(default=100), | ||||
|         ), | ||||
|     ] | ||||
| @ -4,15 +4,21 @@ from itertools import chain | ||||
|  | ||||
| from django.contrib.postgres.fields import ArrayField | ||||
| from django.db import models | ||||
| from django.utils.timezone import now | ||||
| from django.utils.translation import gettext as _ | ||||
| from django_countries.fields import CountryField | ||||
| from geopy import distance | ||||
| from rest_framework.serializers import BaseSerializer | ||||
|  | ||||
| from authentik.events.context_processors.geoip import GeoIPDict | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.policies.exceptions import PolicyException | ||||
| from authentik.policies.geoip.exceptions import GeoIPNotFoundException | ||||
| from authentik.policies.models import Policy | ||||
| from authentik.policies.types import PolicyRequest, PolicyResult | ||||
|  | ||||
| MAX_DISTANCE_HOUR_KM = 1000 | ||||
|  | ||||
|  | ||||
| class GeoIPPolicy(Policy): | ||||
|     """Ensure the user satisfies requirements of geography or network topology, based on IP | ||||
| @ -21,6 +27,15 @@ class GeoIPPolicy(Policy): | ||||
|     asns = ArrayField(models.IntegerField(), blank=True, default=list) | ||||
|     countries = CountryField(multiple=True, blank=True) | ||||
|  | ||||
|     distance_tolerance_km = models.PositiveIntegerField(default=50) | ||||
|  | ||||
|     check_history_distance = models.BooleanField(default=False) | ||||
|     history_max_distance_km = models.PositiveBigIntegerField(default=100) | ||||
|     history_login_count = models.PositiveIntegerField(default=5) | ||||
|  | ||||
|     check_impossible_travel = models.BooleanField(default=False) | ||||
|     impossible_tolerance_km = models.PositiveIntegerField(default=100) | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[BaseSerializer]: | ||||
|         from authentik.policies.geoip.api import GeoIPPolicySerializer | ||||
| @ -37,21 +52,27 @@ class GeoIPPolicy(Policy): | ||||
|         - the client IP is advertised by an autonomous system with ASN in the `asns` | ||||
|         - the client IP is geolocated in a country of `countries` | ||||
|         """ | ||||
|         results: list[PolicyResult] = [] | ||||
|         static_results: list[PolicyResult] = [] | ||||
|         dynamic_results: list[PolicyResult] = [] | ||||
|  | ||||
|         if self.asns: | ||||
|             results.append(self.passes_asn(request)) | ||||
|             static_results.append(self.passes_asn(request)) | ||||
|         if self.countries: | ||||
|             results.append(self.passes_country(request)) | ||||
|             static_results.append(self.passes_country(request)) | ||||
|  | ||||
|         if not results: | ||||
|         if self.check_history_distance or self.check_impossible_travel: | ||||
|             dynamic_results.append(self.passes_distance(request)) | ||||
|  | ||||
|         if not static_results and not dynamic_results: | ||||
|             return PolicyResult(True) | ||||
|  | ||||
|         passing = any(r.passing for r in results) | ||||
|         messages = chain(*[r.messages for r in results]) | ||||
|         passing = any(r.passing for r in static_results) and all(r.passing for r in dynamic_results) | ||||
|         messages = chain( | ||||
|             *[r.messages for r in static_results], *[r.messages for r in dynamic_results] | ||||
|         ) | ||||
|  | ||||
|         result = PolicyResult(passing, *messages) | ||||
|         result.source_results = results | ||||
|         result.source_results = list(chain(static_results, dynamic_results)) | ||||
|  | ||||
|         return result | ||||
|  | ||||
| @ -73,7 +94,7 @@ class GeoIPPolicy(Policy): | ||||
|  | ||||
|     def passes_country(self, request: PolicyRequest) -> PolicyResult: | ||||
|         # This is not a single get chain because `request.context` can contain `{ "geoip": None }`. | ||||
|         geoip_data = request.context.get("geoip") | ||||
|         geoip_data: GeoIPDict | None = request.context.get("geoip") | ||||
|         country = geoip_data.get("country") if geoip_data else None | ||||
|  | ||||
|         if not country: | ||||
| @ -87,6 +108,42 @@ class GeoIPPolicy(Policy): | ||||
|  | ||||
|         return PolicyResult(True) | ||||
|  | ||||
|     def passes_distance(self, request: PolicyRequest) -> PolicyResult: | ||||
|         """Check if current policy execution is out of distance range compared | ||||
|         to previous authentication requests""" | ||||
|         # Get previous login event and GeoIP data | ||||
|         previous_logins = Event.objects.filter( | ||||
|             action=EventAction.LOGIN, user__pk=request.user.pk, context__geo__isnull=False | ||||
|         ).order_by("-created")[: self.history_login_count] | ||||
|         _now = now() | ||||
|         geoip_data: GeoIPDict | None = request.context.get("geoip") | ||||
|         if not geoip_data: | ||||
|             return PolicyResult(False) | ||||
|         for previous_login in previous_logins: | ||||
|             previous_login_geoip: GeoIPDict = previous_login.context["geo"] | ||||
|  | ||||
|             # Figure out distance | ||||
|             dist = distance.geodesic( | ||||
|                 (previous_login_geoip["lat"], previous_login_geoip["long"]), | ||||
|                 (geoip_data["lat"], geoip_data["long"]), | ||||
|             ) | ||||
|             if self.check_history_distance and dist.km >= ( | ||||
|                 self.history_max_distance_km + self.distance_tolerance_km | ||||
|             ): | ||||
|                 return PolicyResult( | ||||
|                     False, _("Distance from previous authentication is larger than threshold.") | ||||
|                 ) | ||||
|             # Check if distance between `previous_login` and now is more | ||||
|             # than max distance per hour times the amount of hours since the previous login | ||||
|             # (round down to the lowest closest time of hours) | ||||
|             # clamped to be at least 1 hour | ||||
|             rel_time_hours = max(int((_now - previous_login.created).total_seconds() / 3600), 1) | ||||
|             if self.check_impossible_travel and dist.km >= ( | ||||
|                 (MAX_DISTANCE_HOUR_KM * rel_time_hours) + self.distance_tolerance_km | ||||
|             ): | ||||
|                 return PolicyResult(False, _("Distance is further than possible.")) | ||||
|         return PolicyResult(True) | ||||
|  | ||||
|     class Meta(Policy.PolicyMeta): | ||||
|         verbose_name = _("GeoIP Policy") | ||||
|         verbose_name_plural = _("GeoIP Policies") | ||||
|  | ||||
| @ -1,8 +1,10 @@ | ||||
| """geoip policy tests""" | ||||
|  | ||||
| from django.test import TestCase | ||||
| from guardian.shortcuts import get_anonymous_user | ||||
|  | ||||
| from authentik.core.tests.utils import create_test_user | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.events.utils import get_user | ||||
| from authentik.policies.engine import PolicyRequest, PolicyResult | ||||
| from authentik.policies.exceptions import PolicyException | ||||
| from authentik.policies.geoip.exceptions import GeoIPNotFoundException | ||||
| @ -14,8 +16,8 @@ class TestGeoIPPolicy(TestCase): | ||||
|  | ||||
|     def setUp(self): | ||||
|         super().setUp() | ||||
|  | ||||
|         self.request = PolicyRequest(get_anonymous_user()) | ||||
|         self.user = create_test_user() | ||||
|         self.request = PolicyRequest(self.user) | ||||
|  | ||||
|         self.context_disabled_geoip = {} | ||||
|         self.context_unknown_ip = {"asn": None, "geoip": None} | ||||
| @ -126,3 +128,70 @@ class TestGeoIPPolicy(TestCase): | ||||
|         result: PolicyResult = policy.passes(self.request) | ||||
|  | ||||
|         self.assertTrue(result.passing) | ||||
|  | ||||
|     def test_history(self): | ||||
|         """Test history checks""" | ||||
|         Event.objects.create( | ||||
|             action=EventAction.LOGIN, | ||||
|             user=get_user(self.user), | ||||
|             context={ | ||||
|                 # Random location in Canada | ||||
|                 "geo": {"lat": 55.868351, "long": -104.441011}, | ||||
|             }, | ||||
|         ) | ||||
|         # Random location in Poland | ||||
|         self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679} | ||||
|  | ||||
|         policy = GeoIPPolicy.objects.create(check_history_distance=True) | ||||
|  | ||||
|         result: PolicyResult = policy.passes(self.request) | ||||
|         self.assertFalse(result.passing) | ||||
|  | ||||
|     def test_history_no_data(self): | ||||
|         """Test history checks (with no geoip data in context)""" | ||||
|         Event.objects.create( | ||||
|             action=EventAction.LOGIN, | ||||
|             user=get_user(self.user), | ||||
|             context={ | ||||
|                 # Random location in Canada | ||||
|                 "geo": {"lat": 55.868351, "long": -104.441011}, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|         policy = GeoIPPolicy.objects.create(check_history_distance=True) | ||||
|  | ||||
|         result: PolicyResult = policy.passes(self.request) | ||||
|         self.assertFalse(result.passing) | ||||
|  | ||||
|     def test_history_impossible_travel(self): | ||||
|         """Test history checks""" | ||||
|         Event.objects.create( | ||||
|             action=EventAction.LOGIN, | ||||
|             user=get_user(self.user), | ||||
|             context={ | ||||
|                 # Random location in Canada | ||||
|                 "geo": {"lat": 55.868351, "long": -104.441011}, | ||||
|             }, | ||||
|         ) | ||||
|         # Random location in Poland | ||||
|         self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679} | ||||
|  | ||||
|         policy = GeoIPPolicy.objects.create(check_impossible_travel=True) | ||||
|  | ||||
|         result: PolicyResult = policy.passes(self.request) | ||||
|         self.assertFalse(result.passing) | ||||
|  | ||||
|     def test_history_no_geoip(self): | ||||
|         """Test history checks (previous login with no geoip data)""" | ||||
|         Event.objects.create( | ||||
|             action=EventAction.LOGIN, | ||||
|             user=get_user(self.user), | ||||
|             context={}, | ||||
|         ) | ||||
|         # Random location in Poland | ||||
|         self.request.context["geoip"] = {"lat": 50.950613, "long": 20.363679} | ||||
|  | ||||
|         policy = GeoIPPolicy.objects.create(check_history_distance=True) | ||||
|  | ||||
|         result: PolicyResult = policy.passes(self.request) | ||||
|         self.assertFalse(result.passing) | ||||
|  | ||||
| @ -148,10 +148,10 @@ class PasswordPolicy(Policy): | ||||
|             user_inputs.append(request.user.email) | ||||
|         if request.http_request: | ||||
|             user_inputs.append(request.http_request.brand.branding_title) | ||||
|         # Only calculate result for the first 100 characters, as with over 100 char | ||||
|         # Only calculate result for the first 72 characters, as with over 100 char | ||||
|         # long passwords we can be reasonably sure that they'll surpass the score anyways | ||||
|         # See https://github.com/dropbox/zxcvbn#runtime-latency | ||||
|         results = zxcvbn(password[:100], user_inputs) | ||||
|         results = zxcvbn(password[:72], user_inputs) | ||||
|         LOGGER.debug("password failed", check="zxcvbn", score=results["score"]) | ||||
|         result = PolicyResult(results["score"] > self.zxcvbn_score_threshold) | ||||
|         if not result.passing: | ||||
|  | ||||
| @ -6,13 +6,12 @@ from rest_framework.viewsets import GenericViewSet | ||||
| from authentik.core.api.groups import GroupMemberSerializer | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import ModelSerializer | ||||
| from authentik.enterprise.api import EnterpriseRequiredMixin | ||||
| from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer | ||||
| from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer | ||||
| from authentik.enterprise.providers.rac.models import ConnectionToken | ||||
| from authentik.providers.rac.api.endpoints import EndpointSerializer | ||||
| from authentik.providers.rac.api.providers import RACProviderSerializer | ||||
| from authentik.providers.rac.models import ConnectionToken | ||||
| 
 | ||||
| 
 | ||||
| class ConnectionTokenSerializer(EnterpriseRequiredMixin, ModelSerializer): | ||||
| class ConnectionTokenSerializer(ModelSerializer): | ||||
|     """ConnectionToken Serializer""" | ||||
| 
 | ||||
|     provider_obj = RACProviderSerializer(source="provider", read_only=True) | ||||
| @ -14,10 +14,9 @@ from structlog.stdlib import get_logger | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import ModelSerializer | ||||
| from authentik.core.models import Provider | ||||
| from authentik.enterprise.api import EnterpriseRequiredMixin | ||||
| from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer | ||||
| from authentik.enterprise.providers.rac.models import Endpoint | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| from authentik.providers.rac.api.providers import RACProviderSerializer | ||||
| from authentik.providers.rac.models import Endpoint | ||||
| from authentik.rbac.filters import ObjectFilter | ||||
| 
 | ||||
| LOGGER = get_logger() | ||||
| @ -28,7 +27,7 @@ def user_endpoint_cache_key(user_pk: str) -> str: | ||||
|     return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}" | ||||
| 
 | ||||
| 
 | ||||
| class EndpointSerializer(EnterpriseRequiredMixin, ModelSerializer): | ||||
| class EndpointSerializer(ModelSerializer): | ||||
|     """Endpoint Serializer""" | ||||
| 
 | ||||
|     provider_obj = RACProviderSerializer(source="provider", read_only=True) | ||||
| @ -10,7 +10,7 @@ from rest_framework.viewsets import ModelViewSet | ||||
| from authentik.core.api.property_mappings import PropertyMappingSerializer | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import JSONDictField | ||||
| from authentik.enterprise.providers.rac.models import RACPropertyMapping | ||||
| from authentik.providers.rac.models import RACPropertyMapping | ||||
| 
 | ||||
| 
 | ||||
| class RACPropertyMappingSerializer(PropertyMappingSerializer): | ||||
| @ -5,11 +5,10 @@ from rest_framework.viewsets import ModelViewSet | ||||
| 
 | ||||
| from authentik.core.api.providers import ProviderSerializer | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.enterprise.api import EnterpriseRequiredMixin | ||||
| from authentik.enterprise.providers.rac.models import RACProvider | ||||
| from authentik.providers.rac.models import RACProvider | ||||
| 
 | ||||
| 
 | ||||
| class RACProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer): | ||||
| class RACProviderSerializer(ProviderSerializer): | ||||
|     """RACProvider Serializer""" | ||||
| 
 | ||||
|     outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all") | ||||
							
								
								
									
										14
									
								
								authentik/providers/rac/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								authentik/providers/rac/apps.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | ||||
| """RAC app config""" | ||||
|  | ||||
| from django.apps import AppConfig | ||||
|  | ||||
|  | ||||
| class AuthentikProviderRAC(AppConfig): | ||||
|     """authentik rac app config""" | ||||
|  | ||||
|     name = "authentik.providers.rac" | ||||
|     label = "authentik_providers_rac" | ||||
|     verbose_name = "authentik Providers.RAC" | ||||
|     default = True | ||||
|     mountpoint = "" | ||||
|     ws_mountpoint = "authentik.providers.rac.urls" | ||||
| @ -7,22 +7,22 @@ from channels.generic.websocket import AsyncWebsocketConsumer | ||||
| from django.http.request import QueryDict | ||||
| from structlog.stdlib import BoundLogger, get_logger | ||||
| 
 | ||||
| from authentik.enterprise.providers.rac.models import ConnectionToken, RACProvider | ||||
| from authentik.outposts.consumer import OUTPOST_GROUP_INSTANCE | ||||
| from authentik.outposts.models import Outpost, OutpostState, OutpostType | ||||
| from authentik.providers.rac.models import ConnectionToken, RACProvider | ||||
| 
 | ||||
| # Global broadcast group, which messages are sent to when the outpost connects back | ||||
| # to authentik for a specific connection | ||||
| # The `RACClientConsumer` consumer adds itself to this group on connection, | ||||
| # and removes itself once it has been assigned a specific outpost channel | ||||
| RAC_CLIENT_GROUP = "group_enterprise_rac_client" | ||||
| RAC_CLIENT_GROUP = "group_rac_client" | ||||
| # A group for all connections in a given authentik session ID | ||||
| # A disconnect message is sent to this group when the session expires/is deleted | ||||
| RAC_CLIENT_GROUP_SESSION = "group_enterprise_rac_client_%(session)s" | ||||
| RAC_CLIENT_GROUP_SESSION = "group_rac_client_%(session)s" | ||||
| # A group for all connections with a specific token, which in almost all cases | ||||
| # is just one connection, however this is used to disconnect the connection | ||||
| # when the token is deleted | ||||
| RAC_CLIENT_GROUP_TOKEN = "group_enterprise_rac_token_%(token)s"  # nosec | ||||
| RAC_CLIENT_GROUP_TOKEN = "group_rac_token_%(token)s"  # nosec | ||||
| 
 | ||||
| # Step 1: Client connects to this websocket endpoint | ||||
| # Step 2: We prepare all the connection args for Guac | ||||
| @ -3,7 +3,7 @@ | ||||
| from channels.exceptions import ChannelFull | ||||
| from channels.generic.websocket import AsyncWebsocketConsumer | ||||
| 
 | ||||
| from authentik.enterprise.providers.rac.consumer_client import RAC_CLIENT_GROUP | ||||
| from authentik.providers.rac.consumer_client import RAC_CLIENT_GROUP | ||||
| 
 | ||||
| 
 | ||||
| class RACOutpostConsumer(AsyncWebsocketConsumer): | ||||
							
								
								
									
										0
									
								
								authentik/providers/rac/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/providers/rac/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @ -74,7 +74,7 @@ class RACProvider(Provider): | ||||
| 
 | ||||
|     @property | ||||
|     def serializer(self) -> type[Serializer]: | ||||
|         from authentik.enterprise.providers.rac.api.providers import RACProviderSerializer | ||||
|         from authentik.providers.rac.api.providers import RACProviderSerializer | ||||
| 
 | ||||
|         return RACProviderSerializer | ||||
| 
 | ||||
| @ -100,7 +100,7 @@ class Endpoint(SerializerModel, PolicyBindingModel): | ||||
| 
 | ||||
|     @property | ||||
|     def serializer(self) -> type[Serializer]: | ||||
|         from authentik.enterprise.providers.rac.api.endpoints import EndpointSerializer | ||||
|         from authentik.providers.rac.api.endpoints import EndpointSerializer | ||||
| 
 | ||||
|         return EndpointSerializer | ||||
| 
 | ||||
| @ -129,7 +129,7 @@ class RACPropertyMapping(PropertyMapping): | ||||
| 
 | ||||
|     @property | ||||
|     def serializer(self) -> type[Serializer]: | ||||
|         from authentik.enterprise.providers.rac.api.property_mappings import ( | ||||
|         from authentik.providers.rac.api.property_mappings import ( | ||||
|             RACPropertyMappingSerializer, | ||||
|         ) | ||||
| 
 | ||||
| @ -10,12 +10,12 @@ from django.dispatch import receiver | ||||
| from django.http import HttpRequest | ||||
| 
 | ||||
| from authentik.core.models import User | ||||
| from authentik.enterprise.providers.rac.api.endpoints import user_endpoint_cache_key | ||||
| from authentik.enterprise.providers.rac.consumer_client import ( | ||||
| from authentik.providers.rac.api.endpoints import user_endpoint_cache_key | ||||
| from authentik.providers.rac.consumer_client import ( | ||||
|     RAC_CLIENT_GROUP_SESSION, | ||||
|     RAC_CLIENT_GROUP_TOKEN, | ||||
| ) | ||||
| from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint | ||||
| from authentik.providers.rac.models import ConnectionToken, Endpoint | ||||
| 
 | ||||
| 
 | ||||
| @receiver(user_logged_out) | ||||
| @ -3,7 +3,7 @@ | ||||
| {% load authentik_core %} | ||||
| 
 | ||||
| {% block head %} | ||||
| <script src="{% versioned_script 'dist/enterprise/rac/index-%v.js' %}" type="module"></script> | ||||
| <script src="{% versioned_script 'dist/rac/index-%v.js' %}" type="module"></script> | ||||
| <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)"> | ||||
| <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> | ||||
| <link rel="icon" href="{{ tenant.branding_favicon_url }}"> | ||||
							
								
								
									
										0
									
								
								authentik/providers/rac/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/providers/rac/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @ -1,16 +1,9 @@ | ||||
| """Test RAC Provider""" | ||||
| 
 | ||||
| from datetime import timedelta | ||||
| from time import mktime | ||||
| from unittest.mock import MagicMock, patch | ||||
| 
 | ||||
| from django.urls import reverse | ||||
| from django.utils.timezone import now | ||||
| from rest_framework.test import APITestCase | ||||
| 
 | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow | ||||
| from authentik.enterprise.license import LicenseKey | ||||
| from authentik.enterprise.models import License | ||||
| from authentik.lib.generators import generate_id | ||||
| 
 | ||||
| 
 | ||||
| @ -20,21 +13,8 @@ class TestAPI(APITestCase): | ||||
|     def setUp(self) -> None: | ||||
|         self.user = create_test_admin_user() | ||||
| 
 | ||||
|     @patch( | ||||
|         "authentik.enterprise.license.LicenseKey.validate", | ||||
|         MagicMock( | ||||
|             return_value=LicenseKey( | ||||
|                 aud="", | ||||
|                 exp=int(mktime((now() + timedelta(days=3000)).timetuple())), | ||||
|                 name=generate_id(), | ||||
|                 internal_users=100, | ||||
|                 external_users=100, | ||||
|             ) | ||||
|         ), | ||||
|     ) | ||||
|     def test_create(self): | ||||
|         """Test creation of RAC Provider""" | ||||
|         License.objects.create(key=generate_id()) | ||||
|         self.client.force_login(self.user) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:racprovider-list"), | ||||
| @ -5,10 +5,10 @@ from rest_framework.test import APITestCase | ||||
| 
 | ||||
| from authentik.core.models import Application | ||||
| from authentik.core.tests.utils import create_test_admin_user | ||||
| from authentik.enterprise.providers.rac.models import Endpoint, Protocols, RACProvider | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.policies.dummy.models import DummyPolicy | ||||
| from authentik.policies.models import PolicyBinding | ||||
| from authentik.providers.rac.models import Endpoint, Protocols, RACProvider | ||||
| 
 | ||||
| 
 | ||||
| class TestEndpointsAPI(APITestCase): | ||||
| @ -4,14 +4,14 @@ from django.test import TransactionTestCase | ||||
| 
 | ||||
| from authentik.core.models import Application, AuthenticatedSession | ||||
| from authentik.core.tests.utils import create_test_admin_user | ||||
| from authentik.enterprise.providers.rac.models import ( | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.providers.rac.models import ( | ||||
|     ConnectionToken, | ||||
|     Endpoint, | ||||
|     Protocols, | ||||
|     RACPropertyMapping, | ||||
|     RACProvider, | ||||
| ) | ||||
| from authentik.lib.generators import generate_id | ||||
| 
 | ||||
| 
 | ||||
| class TestModels(TransactionTestCase): | ||||
| @ -1,23 +1,17 @@ | ||||
| """RAC Views tests""" | ||||
| 
 | ||||
| from datetime import timedelta | ||||
| from json import loads | ||||
| from time import mktime | ||||
| from unittest.mock import MagicMock, patch | ||||
| 
 | ||||
| from django.urls import reverse | ||||
| from django.utils.timezone import now | ||||
| from rest_framework.test import APITestCase | ||||
| 
 | ||||
| from authentik.core.models import Application | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow | ||||
| from authentik.enterprise.license import LicenseKey | ||||
| from authentik.enterprise.models import License | ||||
| from authentik.enterprise.providers.rac.models import Endpoint, Protocols, RACProvider | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.policies.denied import AccessDeniedResponse | ||||
| from authentik.policies.dummy.models import DummyPolicy | ||||
| from authentik.policies.models import PolicyBinding | ||||
| from authentik.providers.rac.models import Endpoint, Protocols, RACProvider | ||||
| 
 | ||||
| 
 | ||||
| class TestRACViews(APITestCase): | ||||
| @ -39,21 +33,8 @@ class TestRACViews(APITestCase): | ||||
|             provider=self.provider, | ||||
|         ) | ||||
| 
 | ||||
|     @patch( | ||||
|         "authentik.enterprise.license.LicenseKey.validate", | ||||
|         MagicMock( | ||||
|             return_value=LicenseKey( | ||||
|                 aud="", | ||||
|                 exp=int(mktime((now() + timedelta(days=3000)).timetuple())), | ||||
|                 name=generate_id(), | ||||
|                 internal_users=100, | ||||
|                 external_users=100, | ||||
|             ) | ||||
|         ), | ||||
|     ) | ||||
|     def test_no_policy(self): | ||||
|         """Test request""" | ||||
|         License.objects.create(key=generate_id()) | ||||
|         self.client.force_login(self.user) | ||||
|         response = self.client.get( | ||||
|             reverse( | ||||
| @ -70,18 +51,6 @@ class TestRACViews(APITestCase): | ||||
|         final_response = self.client.get(next_url) | ||||
|         self.assertEqual(final_response.status_code, 200) | ||||
| 
 | ||||
|     @patch( | ||||
|         "authentik.enterprise.license.LicenseKey.validate", | ||||
|         MagicMock( | ||||
|             return_value=LicenseKey( | ||||
|                 aud="", | ||||
|                 exp=int(mktime((now() + timedelta(days=3000)).timetuple())), | ||||
|                 name=generate_id(), | ||||
|                 internal_users=100, | ||||
|                 external_users=100, | ||||
|             ) | ||||
|         ), | ||||
|     ) | ||||
|     def test_app_deny(self): | ||||
|         """Test request (deny on app level)""" | ||||
|         PolicyBinding.objects.create( | ||||
| @ -89,7 +58,6 @@ class TestRACViews(APITestCase): | ||||
|             policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2), | ||||
|             order=0, | ||||
|         ) | ||||
|         License.objects.create(key=generate_id()) | ||||
|         self.client.force_login(self.user) | ||||
|         response = self.client.get( | ||||
|             reverse( | ||||
| @ -99,18 +67,6 @@ class TestRACViews(APITestCase): | ||||
|         ) | ||||
|         self.assertIsInstance(response, AccessDeniedResponse) | ||||
| 
 | ||||
|     @patch( | ||||
|         "authentik.enterprise.license.LicenseKey.validate", | ||||
|         MagicMock( | ||||
|             return_value=LicenseKey( | ||||
|                 aud="", | ||||
|                 exp=int(mktime((now() + timedelta(days=3000)).timetuple())), | ||||
|                 name=generate_id(), | ||||
|                 internal_users=100, | ||||
|                 external_users=100, | ||||
|             ) | ||||
|         ), | ||||
|     ) | ||||
|     def test_endpoint_deny(self): | ||||
|         """Test request (deny on endpoint level)""" | ||||
|         PolicyBinding.objects.create( | ||||
| @ -118,7 +74,6 @@ class TestRACViews(APITestCase): | ||||
|             policy=DummyPolicy.objects.create(name="deny", result=False, wait_min=1, wait_max=2), | ||||
|             order=0, | ||||
|         ) | ||||
|         License.objects.create(key=generate_id()) | ||||
|         self.client.force_login(self.user) | ||||
|         response = self.client.get( | ||||
|             reverse( | ||||
| @ -4,14 +4,14 @@ from channels.auth import AuthMiddleware | ||||
| from channels.sessions import CookieMiddleware | ||||
| from django.urls import path | ||||
| 
 | ||||
| from authentik.enterprise.providers.rac.api.connection_tokens import ConnectionTokenViewSet | ||||
| from authentik.enterprise.providers.rac.api.endpoints import EndpointViewSet | ||||
| from authentik.enterprise.providers.rac.api.property_mappings import RACPropertyMappingViewSet | ||||
| from authentik.enterprise.providers.rac.api.providers import RACProviderViewSet | ||||
| from authentik.enterprise.providers.rac.consumer_client import RACClientConsumer | ||||
| from authentik.enterprise.providers.rac.consumer_outpost import RACOutpostConsumer | ||||
| from authentik.enterprise.providers.rac.views import RACInterface, RACStartView | ||||
| from authentik.outposts.channels import TokenOutpostMiddleware | ||||
| from authentik.providers.rac.api.connection_tokens import ConnectionTokenViewSet | ||||
| from authentik.providers.rac.api.endpoints import EndpointViewSet | ||||
| from authentik.providers.rac.api.property_mappings import RACPropertyMappingViewSet | ||||
| from authentik.providers.rac.api.providers import RACProviderViewSet | ||||
| from authentik.providers.rac.consumer_client import RACClientConsumer | ||||
| from authentik.providers.rac.consumer_outpost import RACOutpostConsumer | ||||
| from authentik.providers.rac.views import RACInterface, RACStartView | ||||
| from authentik.root.asgi_middleware import SessionMiddleware | ||||
| from authentik.root.middleware import ChannelsLoggingMiddleware | ||||
| 
 | ||||
| @ -10,8 +10,6 @@ from django.utils.translation import gettext as _ | ||||
| 
 | ||||
| from authentik.core.models import Application, AuthenticatedSession | ||||
| from authentik.core.views.interface import InterfaceView | ||||
| from authentik.enterprise.policy import EnterprisePolicyAccessView | ||||
| from authentik.enterprise.providers.rac.models import ConnectionToken, Endpoint, RACProvider | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.flows.challenge import RedirectChallenge | ||||
| from authentik.flows.exceptions import FlowNonApplicableException | ||||
| @ -20,9 +18,11 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | ||||
| from authentik.flows.stage import RedirectStage | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| from authentik.policies.views import PolicyAccessView | ||||
| from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider | ||||
| 
 | ||||
| 
 | ||||
| class RACStartView(EnterprisePolicyAccessView): | ||||
| class RACStartView(PolicyAccessView): | ||||
|     """Start a RAC connection by checking access and creating a connection token""" | ||||
| 
 | ||||
|     endpoint: Endpoint | ||||
| @ -19,7 +19,6 @@ from authentik.lib.sync.outgoing.exceptions import ( | ||||
|     TransientSyncException, | ||||
| ) | ||||
| from authentik.lib.utils.http import get_http_session | ||||
| from authentik.outposts.http import OutpostHTTPAdapter | ||||
| from authentik.providers.scim.clients.exceptions import SCIMRequestException | ||||
| from authentik.providers.scim.clients.schema import ServiceProviderConfiguration | ||||
| from authentik.providers.scim.models import SCIMProvider | ||||
| @ -42,7 +41,8 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"]( | ||||
|  | ||||
|     def __init__(self, provider: SCIMProvider): | ||||
|         super().__init__(provider) | ||||
|         self._session = self.get_session(provider) | ||||
|         self._session = get_http_session() | ||||
|         self._session.verify = provider.verify_certificates | ||||
|         self.provider = provider | ||||
|         # Remove trailing slashes as we assume the URL doesn't have any | ||||
|         base_url = provider.url | ||||
| @ -52,15 +52,6 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"]( | ||||
|         self.token = provider.token | ||||
|         self._config = self.get_service_provider_config() | ||||
|  | ||||
|     def get_session(self, provider: SCIMProvider): | ||||
|         session = get_http_session() | ||||
|         if self.provider.outpost_set.exists(): | ||||
|             adapter = OutpostHTTPAdapter() | ||||
|             session.mount("https://", adapter) | ||||
|             session.mount("http://", adapter) | ||||
|         session.verify = provider.verify_certificates | ||||
|         return session | ||||
|  | ||||
|     def _request(self, method: str, path: str, **kwargs) -> dict: | ||||
|         """Wrapper to send a request to the full URL""" | ||||
|         try: | ||||
|  | ||||
| @ -1,12 +0,0 @@ | ||||
| """SCIM Provider Docker Controller""" | ||||
|  | ||||
| from authentik.outposts.controllers.docker import DockerController | ||||
| from authentik.outposts.models import DockerServiceConnection, Outpost | ||||
|  | ||||
|  | ||||
| class SCIMDockerController(DockerController): | ||||
|     """SCIM Provider Docker Controller""" | ||||
|  | ||||
|     def __init__(self, outpost: Outpost, connection: DockerServiceConnection): | ||||
|         super().__init__(outpost, connection) | ||||
|         self.deployment_ports = [] | ||||
| @ -1,14 +0,0 @@ | ||||
| """SCIM Provider Kubernetes Controller""" | ||||
|  | ||||
| from authentik.outposts.controllers.k8s.service import ServiceReconciler | ||||
| from authentik.outposts.controllers.kubernetes import KubernetesController | ||||
| from authentik.outposts.models import KubernetesServiceConnection, Outpost | ||||
|  | ||||
|  | ||||
| class SCIMKubernetesController(KubernetesController): | ||||
|     """SCIM Provider Kubernetes Controller""" | ||||
|  | ||||
|     def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection): | ||||
|         super().__init__(outpost, connection) | ||||
|         self.deployment_ports = [] | ||||
|         del self.reconcilers[ServiceReconciler.reconciler_name()] | ||||
| @ -2,7 +2,7 @@ | ||||
|  | ||||
| from django.apps import apps | ||||
| from django.contrib.auth.models import Permission | ||||
| from django.db.models import Q, QuerySet | ||||
| from django.db.models import QuerySet | ||||
| from django_filters.filters import ModelChoiceFilter | ||||
| from django_filters.filterset import FilterSet | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| @ -18,7 +18,6 @@ from rest_framework.filters import OrderingFilter, SearchFilter | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.viewsets import ReadOnlyModelViewSet | ||||
|  | ||||
| from authentik.blueprints.v1.importer import excluded_models | ||||
| from authentik.core.api.utils import ModelSerializer, PassiveSerializer | ||||
| from authentik.core.models import User | ||||
| from authentik.lib.validators import RequiredTogetherValidator | ||||
| @ -106,13 +105,13 @@ class RBACPermissionViewSet(ReadOnlyModelViewSet): | ||||
|     ] | ||||
|  | ||||
|     def get_queryset(self) -> QuerySet: | ||||
|         query = Q() | ||||
|         for model in excluded_models(): | ||||
|             query |= Q( | ||||
|                 content_type__app_label=model._meta.app_label, | ||||
|                 content_type__model=model._meta.model_name, | ||||
|         return ( | ||||
|             Permission.objects.all() | ||||
|             .select_related("content_type") | ||||
|             .filter( | ||||
|                 content_type__app_label__startswith="authentik", | ||||
|             ) | ||||
|         return Permission.objects.all().select_related("content_type").exclude(query) | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class PermissionAssignSerializer(PassiveSerializer): | ||||
|  | ||||
| @ -16,6 +16,7 @@ from authentik.lib.config import CONFIG, django_db_config, redis_url | ||||
| from authentik.lib.logging import get_logger_config, structlog_configure | ||||
| from authentik.lib.sentry import sentry_init | ||||
| from authentik.lib.utils.reflection import get_env | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_INBUILT, BACKEND_LDAP | ||||
|  | ||||
| BASE_DIR = Path(__file__).absolute().parent.parent.parent | ||||
| @ -87,6 +88,7 @@ TENANT_APPS = [ | ||||
|     "authentik.providers.ldap", | ||||
|     "authentik.providers.oauth2", | ||||
|     "authentik.providers.proxy", | ||||
|     "authentik.providers.rac", | ||||
|     "authentik.providers.radius", | ||||
|     "authentik.providers.saml", | ||||
|     "authentik.providers.scim", | ||||
| @ -100,6 +102,7 @@ TENANT_APPS = [ | ||||
|     "authentik.sources.scim", | ||||
|     "authentik.stages.authenticator", | ||||
|     "authentik.stages.authenticator_duo", | ||||
|     "authentik.stages.authenticator_email", | ||||
|     "authentik.stages.authenticator_sms", | ||||
|     "authentik.stages.authenticator_static", | ||||
|     "authentik.stages.authenticator_totp", | ||||
| @ -122,6 +125,7 @@ TENANT_APPS = [ | ||||
|     "authentik.brands", | ||||
|     "authentik.blueprints", | ||||
|     "guardian", | ||||
|     "django_celery_beat", | ||||
| ] | ||||
|  | ||||
| TENANT_MODEL = "authentik_tenants.Tenant" | ||||
| @ -240,6 +244,9 @@ SESSION_CACHE_ALIAS = "default" | ||||
| # Configured via custom SessionMiddleware | ||||
| # SESSION_COOKIE_SAMESITE = "None" | ||||
| # SESSION_COOKIE_SECURE = True | ||||
| SESSION_COOKIE_AGE = timedelta_from_string( | ||||
|     CONFIG.get("sessions.unauthenticated_age", "days=1") | ||||
| ).total_seconds() | ||||
| SESSION_EXPIRE_AT_BROWSER_CLOSE = True | ||||
|  | ||||
| MESSAGE_STORAGE = "authentik.root.messages.storage.ChannelsStorage" | ||||
|  | ||||
| @ -2,6 +2,7 @@ | ||||
|  | ||||
| from typing import Any | ||||
|  | ||||
| from requests import RequestException | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.sources.oauth.clients.oauth2 import UserprofileHeaderAuthClient | ||||
| @ -21,10 +22,35 @@ class AzureADOAuthRedirect(OAuthRedirect): | ||||
|         } | ||||
|  | ||||
|  | ||||
| class AzureADClient(UserprofileHeaderAuthClient): | ||||
|     """Fetch AzureAD group information""" | ||||
|  | ||||
|     def get_profile_info(self, token): | ||||
|         profile_data = super().get_profile_info(token) | ||||
|         if "https://graph.microsoft.com/GroupMember.Read.All" not in self.source.additional_scopes: | ||||
|             return profile_data | ||||
|         group_response = self.session.request( | ||||
|             "get", | ||||
|             "https://graph.microsoft.com/v1.0/me/memberOf", | ||||
|             headers={"Authorization": f"{token['token_type']} {token['access_token']}"}, | ||||
|         ) | ||||
|         try: | ||||
|             group_response.raise_for_status() | ||||
|         except RequestException as exc: | ||||
|             LOGGER.warning( | ||||
|                 "Unable to fetch user profile", | ||||
|                 exc=exc, | ||||
|                 response=exc.response.text if exc.response else str(exc), | ||||
|             ) | ||||
|             return None | ||||
|         profile_data["raw_groups"] = group_response.json() | ||||
|         return profile_data | ||||
|  | ||||
|  | ||||
| class AzureADOAuthCallback(OpenIDConnectOAuth2Callback): | ||||
|     """AzureAD OAuth2 Callback""" | ||||
|  | ||||
|     client_class = UserprofileHeaderAuthClient | ||||
|     client_class = AzureADClient | ||||
|  | ||||
|     def get_user_id(self, info: dict[str, str]) -> str: | ||||
|         # Default try to get `id` for the Graph API endpoint | ||||
| @ -53,8 +79,24 @@ class AzureADType(SourceType): | ||||
|  | ||||
|     def get_base_user_properties(self, info: dict[str, Any], **kwargs) -> dict[str, Any]: | ||||
|         mail = info.get("mail", None) or info.get("otherMails", [None])[0] | ||||
|         # Format group info | ||||
|         groups = [] | ||||
|         group_id_dict = {} | ||||
|         for group in info.get("raw_groups", {}).get("value", []): | ||||
|             if group["@odata.type"] != "#microsoft.graph.group": | ||||
|                 continue | ||||
|             groups.append(group["id"]) | ||||
|             group_id_dict[group["id"]] = group | ||||
|         info["raw_groups"] = group_id_dict | ||||
|         return { | ||||
|             "username": info.get("userPrincipalName"), | ||||
|             "email": mail, | ||||
|             "name": info.get("displayName"), | ||||
|             "groups": groups, | ||||
|         } | ||||
|  | ||||
|     def get_base_group_properties(self, source, group_id, **kwargs): | ||||
|         raw_group = kwargs["info"]["raw_groups"][group_id] | ||||
|         return { | ||||
|             "name": raw_group["displayName"], | ||||
|         } | ||||
|  | ||||
							
								
								
									
										0
									
								
								authentik/stages/authenticator_email/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/stages/authenticator_email/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										85
									
								
								authentik/stages/authenticator_email/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								authentik/stages/authenticator_email/api.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,85 @@ | ||||
| """AuthenticatorEmailStage API Views""" | ||||
|  | ||||
| from rest_framework import mixins | ||||
| from rest_framework.viewsets import GenericViewSet, ModelViewSet | ||||
|  | ||||
| from authentik.core.api.groups import GroupMemberSerializer | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import ModelSerializer | ||||
| from authentik.flows.api.stages import StageSerializer | ||||
| from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice | ||||
|  | ||||
|  | ||||
| class AuthenticatorEmailStageSerializer(StageSerializer): | ||||
|     """AuthenticatorEmailStage Serializer""" | ||||
|  | ||||
|     class Meta: | ||||
|         model = AuthenticatorEmailStage | ||||
|         fields = StageSerializer.Meta.fields + [ | ||||
|             "configure_flow", | ||||
|             "friendly_name", | ||||
|             "use_global_settings", | ||||
|             "host", | ||||
|             "port", | ||||
|             "username", | ||||
|             "password", | ||||
|             "use_tls", | ||||
|             "use_ssl", | ||||
|             "timeout", | ||||
|             "from_address", | ||||
|             "subject", | ||||
|             "token_expiry", | ||||
|             "template", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class AuthenticatorEmailStageViewSet(UsedByMixin, ModelViewSet): | ||||
|     """AuthenticatorEmailStage Viewset""" | ||||
|  | ||||
|     queryset = AuthenticatorEmailStage.objects.all() | ||||
|     serializer_class = AuthenticatorEmailStageSerializer | ||||
|     filterset_fields = "__all__" | ||||
|     ordering = ["name"] | ||||
|     search_fields = ["name"] | ||||
|  | ||||
|  | ||||
| class EmailDeviceSerializer(ModelSerializer): | ||||
|     """Serializer for email authenticator devices""" | ||||
|  | ||||
|     user = GroupMemberSerializer(read_only=True) | ||||
|  | ||||
|     class Meta: | ||||
|         model = EmailDevice | ||||
|         fields = ["name", "pk", "email", "user"] | ||||
|         depth = 2 | ||||
|         extra_kwargs = { | ||||
|             "email": {"read_only": True}, | ||||
|         } | ||||
|  | ||||
|  | ||||
| class EmailDeviceViewSet( | ||||
|     mixins.RetrieveModelMixin, | ||||
|     mixins.UpdateModelMixin, | ||||
|     mixins.DestroyModelMixin, | ||||
|     UsedByMixin, | ||||
|     mixins.ListModelMixin, | ||||
|     GenericViewSet, | ||||
| ): | ||||
|     """Viewset for email authenticator devices""" | ||||
|  | ||||
|     queryset = EmailDevice.objects.all() | ||||
|     serializer_class = EmailDeviceSerializer | ||||
|     search_fields = ["name"] | ||||
|     filterset_fields = ["name"] | ||||
|     ordering = ["name"] | ||||
|     owner_field = "user" | ||||
|  | ||||
|  | ||||
| class EmailAdminDeviceViewSet(ModelViewSet): | ||||
|     """Viewset for email authenticator devices (for admins)""" | ||||
|  | ||||
|     queryset = EmailDevice.objects.all() | ||||
|     serializer_class = EmailDeviceSerializer | ||||
|     search_fields = ["name"] | ||||
|     filterset_fields = ["name"] | ||||
|     ordering = ["name"] | ||||
							
								
								
									
										12
									
								
								authentik/stages/authenticator_email/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								authentik/stages/authenticator_email/apps.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| """Email Authenticator""" | ||||
|  | ||||
| from authentik.blueprints.apps import ManagedAppConfig | ||||
|  | ||||
|  | ||||
| class AuthentikStageAuthenticatorEmailConfig(ManagedAppConfig): | ||||
|     """Email Authenticator App config""" | ||||
|  | ||||
|     name = "authentik.stages.authenticator_email" | ||||
|     label = "authentik_stages_authenticator_email" | ||||
|     verbose_name = "authentik Stages.Authenticator.Email" | ||||
|     default = True | ||||
							
								
								
									
										132
									
								
								authentik/stages/authenticator_email/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								authentik/stages/authenticator_email/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,132 @@ | ||||
| # Generated by Django 5.0.10 on 2025-01-27 20:05 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| import django.utils.timezone | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
|  | ||||
| import authentik.lib.utils.time | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     initial = True | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_flows", "0027_auto_20231028_1424"), | ||||
|         migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="AuthenticatorEmailStage", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "stage_ptr", | ||||
|                     models.OneToOneField( | ||||
|                         auto_created=True, | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         parent_link=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         to="authentik_flows.stage", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("friendly_name", models.TextField(null=True)), | ||||
|                 ( | ||||
|                     "use_global_settings", | ||||
|                     models.BooleanField( | ||||
|                         default=False, | ||||
|                         help_text="When enabled, global Email connection settings will be used and connection settings below will be ignored.", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("host", models.TextField(default="localhost")), | ||||
|                 ("port", models.IntegerField(default=25)), | ||||
|                 ("username", models.TextField(blank=True, default="")), | ||||
|                 ("password", models.TextField(blank=True, default="")), | ||||
|                 ("use_tls", models.BooleanField(default=False)), | ||||
|                 ("use_ssl", models.BooleanField(default=False)), | ||||
|                 ("timeout", models.IntegerField(default=10)), | ||||
|                 ( | ||||
|                     "from_address", | ||||
|                     models.EmailField(default="system@authentik.local", max_length=254), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "token_expiry", | ||||
|                     models.TextField( | ||||
|                         default="minutes=30", | ||||
|                         help_text="Time the token sent is valid (Format: hours=3,minutes=17,seconds=300).", | ||||
|                         validators=[authentik.lib.utils.time.timedelta_string_validator], | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("subject", models.TextField(default="authentik Sign-in code")), | ||||
|                 ("template", models.TextField(default="email/email_otp.html")), | ||||
|                 ( | ||||
|                     "configure_flow", | ||||
|                     models.ForeignKey( | ||||
|                         blank=True, | ||||
|                         help_text="Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.", | ||||
|                         null=True, | ||||
|                         on_delete=django.db.models.deletion.SET_NULL, | ||||
|                         to="authentik_flows.flow", | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "Email Authenticator Setup Stage", | ||||
|                 "verbose_name_plural": "Email Authenticator Setup Stages", | ||||
|             }, | ||||
|             bases=("authentik_flows.stage", models.Model), | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name="EmailDevice", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "id", | ||||
|                     models.AutoField( | ||||
|                         auto_created=True, primary_key=True, serialize=False, verbose_name="ID" | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("created", models.DateTimeField(auto_now_add=True)), | ||||
|                 ("last_updated", models.DateTimeField(auto_now=True)), | ||||
|                 ( | ||||
|                     "name", | ||||
|                     models.CharField( | ||||
|                         help_text="The human-readable name of this device.", max_length=64 | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "confirmed", | ||||
|                     models.BooleanField(default=True, help_text="Is this device ready for use?"), | ||||
|                 ), | ||||
|                 ("token", models.CharField(blank=True, max_length=16, null=True)), | ||||
|                 ( | ||||
|                     "valid_until", | ||||
|                     models.DateTimeField( | ||||
|                         default=django.utils.timezone.now, | ||||
|                         help_text="The timestamp of the moment of expiry of the saved token.", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("email", models.EmailField(max_length=254)), | ||||
|                 ("last_used", models.DateTimeField(auto_now=True)), | ||||
|                 ( | ||||
|                     "stage", | ||||
|                     models.ForeignKey( | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         to="authentik_stages_authenticator_email.authenticatoremailstage", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ( | ||||
|                     "user", | ||||
|                     models.ForeignKey( | ||||
|                         on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "Email Device", | ||||
|                 "verbose_name_plural": "Email Devices", | ||||
|                 "unique_together": {("user", "email")}, | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										167
									
								
								authentik/stages/authenticator_email/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								authentik/stages/authenticator_email/models.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,167 @@ | ||||
| from django.contrib.auth import get_user_model | ||||
| from django.core.mail.backends.base import BaseEmailBackend | ||||
| from django.core.mail.backends.smtp import EmailBackend | ||||
| from django.db import models | ||||
| from django.template import TemplateSyntaxError | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from django.views import View | ||||
| from rest_framework.serializers import BaseSerializer | ||||
|  | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.flows.exceptions import StageInvalidException | ||||
| from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.models import SerializerModel | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.lib.utils.time import timedelta_string_validator | ||||
| from authentik.stages.authenticator.models import SideChannelDevice | ||||
| from authentik.stages.email.utils import TemplateEmailMessage | ||||
|  | ||||
|  | ||||
| class EmailTemplates(models.TextChoices): | ||||
|     """Templates used for rendering the Email""" | ||||
|  | ||||
|     EMAIL_OTP = ( | ||||
|         "email/email_otp.html", | ||||
|         _("Email OTP"), | ||||
|     )  # nosec | ||||
|  | ||||
|  | ||||
| class AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage): | ||||
|     """Use Email-based authentication instead of authenticator-based.""" | ||||
|  | ||||
|     use_global_settings = models.BooleanField( | ||||
|         default=False, | ||||
|         help_text=_( | ||||
|             "When enabled, global Email connection settings will be used and " | ||||
|             "connection settings below will be ignored." | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|     host = models.TextField(default="localhost") | ||||
|     port = models.IntegerField(default=25) | ||||
|     username = models.TextField(default="", blank=True) | ||||
|     password = models.TextField(default="", blank=True) | ||||
|     use_tls = models.BooleanField(default=False) | ||||
|     use_ssl = models.BooleanField(default=False) | ||||
|     timeout = models.IntegerField(default=10) | ||||
|     from_address = models.EmailField(default="system@authentik.local") | ||||
|  | ||||
|     token_expiry = models.TextField( | ||||
|         default="minutes=30", | ||||
|         validators=[timedelta_string_validator], | ||||
|         help_text=_("Time the token sent is valid (Format: hours=3,minutes=17,seconds=300)."), | ||||
|     ) | ||||
|     subject = models.TextField(default="authentik Sign-in code") | ||||
|     template = models.TextField(default=EmailTemplates.EMAIL_OTP) | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[BaseSerializer]: | ||||
|         from authentik.stages.authenticator_email.api import AuthenticatorEmailStageSerializer | ||||
|  | ||||
|         return AuthenticatorEmailStageSerializer | ||||
|  | ||||
|     @property | ||||
|     def view(self) -> type[View]: | ||||
|         from authentik.stages.authenticator_email.stage import AuthenticatorEmailStageView | ||||
|  | ||||
|         return AuthenticatorEmailStageView | ||||
|  | ||||
|     @property | ||||
|     def component(self) -> str: | ||||
|         return "ak-stage-authenticator-email-form" | ||||
|  | ||||
|     @property | ||||
|     def backend_class(self) -> type[BaseEmailBackend]: | ||||
|         """Get the email backend class to use""" | ||||
|         return EmailBackend | ||||
|  | ||||
|     @property | ||||
|     def backend(self) -> BaseEmailBackend: | ||||
|         """Get fully configured Email Backend instance""" | ||||
|         if self.use_global_settings: | ||||
|             CONFIG.refresh("email.password") | ||||
|             return self.backend_class( | ||||
|                 host=CONFIG.get("email.host"), | ||||
|                 port=CONFIG.get_int("email.port"), | ||||
|                 username=CONFIG.get("email.username"), | ||||
|                 password=CONFIG.get("email.password"), | ||||
|                 use_tls=CONFIG.get_bool("email.use_tls", False), | ||||
|                 use_ssl=CONFIG.get_bool("email.use_ssl", False), | ||||
|                 timeout=CONFIG.get_int("email.timeout"), | ||||
|             ) | ||||
|         return self.backend_class( | ||||
|             host=self.host, | ||||
|             port=self.port, | ||||
|             username=self.username, | ||||
|             password=self.password, | ||||
|             use_tls=self.use_tls, | ||||
|             use_ssl=self.use_ssl, | ||||
|             timeout=self.timeout, | ||||
|         ) | ||||
|  | ||||
|     def send(self, device: "EmailDevice"): | ||||
|         # Lazy import here to avoid circular import | ||||
|         from authentik.stages.email.tasks import send_mails | ||||
|  | ||||
|         # Compose the message using templates | ||||
|         message = device._compose_email() | ||||
|         return send_mails(device.stage, message) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"Email Authenticator Stage {self.name}" | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("Email Authenticator Setup Stage") | ||||
|         verbose_name_plural = _("Email Authenticator Setup Stages") | ||||
|  | ||||
|  | ||||
| class EmailDevice(SerializerModel, SideChannelDevice): | ||||
|     """Email Device""" | ||||
|  | ||||
|     user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) | ||||
|     email = models.EmailField() | ||||
|     stage = models.ForeignKey(AuthenticatorEmailStage, on_delete=models.CASCADE) | ||||
|     last_used = models.DateTimeField(auto_now=True) | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[BaseSerializer]: | ||||
|         from authentik.stages.authenticator_email.api import EmailDeviceSerializer | ||||
|  | ||||
|         return EmailDeviceSerializer | ||||
|  | ||||
|     def _compose_email(self) -> TemplateEmailMessage: | ||||
|         try: | ||||
|             pending_user = self.user | ||||
|             stage = self.stage | ||||
|             email = self.email | ||||
|  | ||||
|             message = TemplateEmailMessage( | ||||
|                 subject=_(stage.subject), | ||||
|                 to=[(pending_user.name, email)], | ||||
|                 template_name=stage.template, | ||||
|                 template_context={ | ||||
|                     "user": pending_user, | ||||
|                     "expires": self.valid_until, | ||||
|                     "token": self.token, | ||||
|                 }, | ||||
|             ) | ||||
|             return message | ||||
|         except TemplateSyntaxError as exc: | ||||
|             Event.new( | ||||
|                 EventAction.CONFIGURATION_ERROR, | ||||
|                 message=_("Exception occurred while rendering E-mail template"), | ||||
|                 error=exception_to_string(exc), | ||||
|                 template=stage.template, | ||||
|             ).from_http(self.request) | ||||
|             raise StageInvalidException from exc | ||||
|  | ||||
|     def __str__(self): | ||||
|         if not self.pk: | ||||
|             return "New Email Device" | ||||
|         return f"Email Device for {self.user_id}" | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("Email Device") | ||||
|         verbose_name_plural = _("Email Devices") | ||||
|         unique_together = (("user", "email"),) | ||||
							
								
								
									
										177
									
								
								authentik/stages/authenticator_email/stage.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								authentik/stages/authenticator_email/stage.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,177 @@ | ||||
| """Email Setup stage""" | ||||
|  | ||||
| from django.db.models import Q | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from django.http.request import QueryDict | ||||
| from django.template.exceptions import TemplateSyntaxError | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.fields import BooleanField, CharField, IntegerField | ||||
|  | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.flows.challenge import ( | ||||
|     Challenge, | ||||
|     ChallengeResponse, | ||||
|     WithUserInfoChallenge, | ||||
| ) | ||||
| from authentik.flows.exceptions import StageInvalidException | ||||
| from authentik.flows.stage import ChallengeStageView | ||||
| from authentik.lib.utils.email import mask_email | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.stages.authenticator_email.models import ( | ||||
|     AuthenticatorEmailStage, | ||||
|     EmailDevice, | ||||
| ) | ||||
| from authentik.stages.email.tasks import send_mails | ||||
| from authentik.stages.email.utils import TemplateEmailMessage | ||||
| from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT | ||||
|  | ||||
| SESSION_KEY_EMAIL_DEVICE = "authentik/stages/authenticator_email/email_device" | ||||
| PLAN_CONTEXT_EMAIL = "email" | ||||
| PLAN_CONTEXT_EMAIL_SENT = "email_sent" | ||||
| PLAN_CONTEXT_EMAIL_OVERRIDE = "email" | ||||
|  | ||||
|  | ||||
| class AuthenticatorEmailChallenge(WithUserInfoChallenge): | ||||
|     """Authenticator Email Setup challenge""" | ||||
|  | ||||
|     # Set to true if no previous prompt stage set the email | ||||
|     # this stage will also check prompt_data.email | ||||
|     email = CharField(default=None, allow_blank=True, allow_null=True) | ||||
|     email_required = BooleanField(default=True) | ||||
|     component = CharField(default="ak-stage-authenticator-email") | ||||
|  | ||||
|  | ||||
| class AuthenticatorEmailChallengeResponse(ChallengeResponse): | ||||
|     """Authenticator Email Challenge response, device is set by get_response_instance""" | ||||
|  | ||||
|     device: EmailDevice | ||||
|  | ||||
|     code = IntegerField(required=False) | ||||
|     email = CharField(required=False) | ||||
|  | ||||
|     component = CharField(default="ak-stage-authenticator-email") | ||||
|  | ||||
|     def validate(self, attrs: dict) -> dict: | ||||
|         """Check""" | ||||
|         if "code" not in attrs: | ||||
|             if "email" not in attrs: | ||||
|                 raise ValidationError("email required") | ||||
|             self.device.email = attrs["email"] | ||||
|             self.stage.validate_and_send(attrs["email"]) | ||||
|             return super().validate(attrs) | ||||
|         if not self.device.verify_token(str(attrs["code"])): | ||||
|             raise ValidationError(_("Code does not match")) | ||||
|         self.device.confirmed = True | ||||
|         return super().validate(attrs) | ||||
|  | ||||
|  | ||||
| class AuthenticatorEmailStageView(ChallengeStageView): | ||||
|     """Authenticator Email Setup stage""" | ||||
|  | ||||
|     response_class = AuthenticatorEmailChallengeResponse | ||||
|  | ||||
|     def validate_and_send(self, email: str): | ||||
|         """Validate email and send message""" | ||||
|         pending_user = self.get_pending_user() | ||||
|  | ||||
|         stage: AuthenticatorEmailStage = self.executor.current_stage | ||||
|         if EmailDevice.objects.filter(Q(email=email), stage=stage.pk).exists(): | ||||
|             raise ValidationError(_("Invalid email")) | ||||
|  | ||||
|         device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE] | ||||
|  | ||||
|         try: | ||||
|             message = TemplateEmailMessage( | ||||
|                 subject=_(stage.subject), | ||||
|                 to=[(pending_user.name, email)], | ||||
|                 language=pending_user.locale(self.request), | ||||
|                 template_name=stage.template, | ||||
|                 template_context={ | ||||
|                     "user": pending_user, | ||||
|                     "expires": device.valid_until, | ||||
|                     "token": device.token, | ||||
|                 }, | ||||
|             ) | ||||
|  | ||||
|             send_mails(stage, message) | ||||
|         except TemplateSyntaxError as exc: | ||||
|             Event.new( | ||||
|                 EventAction.CONFIGURATION_ERROR, | ||||
|                 message=_("Exception occurred while rendering E-mail template"), | ||||
|                 error=exception_to_string(exc), | ||||
|                 template=stage.template, | ||||
|             ).from_http(self.request) | ||||
|             raise StageInvalidException from exc | ||||
|  | ||||
|     def _has_email(self) -> str | None: | ||||
|         context = self.executor.plan.context | ||||
|  | ||||
|         # Check user's email attribute | ||||
|         user = self.get_pending_user() | ||||
|         if user.email: | ||||
|             self.logger.debug("got email from user attributes") | ||||
|             return user.email | ||||
|         # Check plan context for email | ||||
|         if PLAN_CONTEXT_EMAIL in context.get(PLAN_CONTEXT_PROMPT, {}): | ||||
|             self.logger.debug("got email from plan context") | ||||
|             return context.get(PLAN_CONTEXT_PROMPT, {}).get(PLAN_CONTEXT_EMAIL) | ||||
|         # Check device for email | ||||
|         if SESSION_KEY_EMAIL_DEVICE in self.request.session: | ||||
|             self.logger.debug("got email from device in session") | ||||
|             device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE] | ||||
|             if device.email == "": | ||||
|                 return None | ||||
|             return device.email | ||||
|         return None | ||||
|  | ||||
|     def get_challenge(self, *args, **kwargs) -> Challenge: | ||||
|         email = self._has_email() | ||||
|         return AuthenticatorEmailChallenge( | ||||
|             data={ | ||||
|                 "email": mask_email(email), | ||||
|                 "email_required": email is None, | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|     def get_response_instance(self, data: QueryDict) -> ChallengeResponse: | ||||
|         response = super().get_response_instance(data) | ||||
|         response.device = self.request.session[SESSION_KEY_EMAIL_DEVICE] | ||||
|         return response | ||||
|  | ||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||
|         user = self.get_pending_user() | ||||
|  | ||||
|         stage: AuthenticatorEmailStage = self.executor.current_stage | ||||
|         if SESSION_KEY_EMAIL_DEVICE not in self.request.session: | ||||
|             device = EmailDevice(user=user, confirmed=False, stage=stage, name="Email Device") | ||||
|             valid_secs: int = timedelta_from_string(stage.token_expiry).total_seconds() | ||||
|             device.generate_token(valid_secs=valid_secs, commit=False) | ||||
|             self.request.session[SESSION_KEY_EMAIL_DEVICE] = device | ||||
|             if email := self._has_email(): | ||||
|                 device.email = email | ||||
|                 try: | ||||
|                     self.validate_and_send(email) | ||||
|                 except ValidationError as exc: | ||||
|                     # We had an email given already (at this point only possible from flow | ||||
|                     # context), but an error occurred while sending (most likely) | ||||
|                     # due to a duplicate device, so delete the email we got given, reset the state | ||||
|                     # (ish) and retry | ||||
|                     device.email = "" | ||||
|                     self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}).pop( | ||||
|                         PLAN_CONTEXT_EMAIL, None | ||||
|                     ) | ||||
|                     self.request.session.pop(SESSION_KEY_EMAIL_DEVICE, None) | ||||
|                     self.logger.warning("failed to send email to pre-set address", exc=exc) | ||||
|                     return self.get(request, *args, **kwargs) | ||||
|         return super().get(request, *args, **kwargs) | ||||
|  | ||||
|     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: | ||||
|         """Email Token is validated by challenge""" | ||||
|         device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE] | ||||
|         if not device.confirmed: | ||||
|             return self.challenge_invalid(response) | ||||
|         device.save() | ||||
|         del self.request.session[SESSION_KEY_EMAIL_DEVICE] | ||||
|         return self.executor.stage_ok() | ||||
| @ -0,0 +1,44 @@ | ||||
| {% extends "email/base.html" %} | ||||
|  | ||||
| {% load i18n %} | ||||
| {% load humanize %} | ||||
|  | ||||
| {% block content %} | ||||
| <tr> | ||||
|   <td align="center"> | ||||
|     <h1> | ||||
|       {% blocktrans with username=user.username %} | ||||
|       Hi {{ username }}, | ||||
|       {% endblocktrans %} | ||||
|     </h1> | ||||
|   </td> | ||||
| </tr> | ||||
| <tr> | ||||
|   <td align="center"> | ||||
|     <table border="0"> | ||||
|       <tr> | ||||
|         <td align="center" style="max-width: 300px; padding: 20px 0; color: #212124;"> | ||||
|           {% blocktrans %} | ||||
|           Email MFA code. | ||||
|           {% endblocktrans %} | ||||
|         </td> | ||||
|       </tr> | ||||
|       <tr> | ||||
|         <td align="center" class="btn btn-primary"> | ||||
|           {{ token }} | ||||
|         </td> | ||||
|       </tr> | ||||
|     </table> | ||||
|   </td> | ||||
| </tr> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block sub_content %} | ||||
| <tr> | ||||
|   <td style="padding: 20px; font-size: 12px; color: #212124;" align="center"> | ||||
|     {% blocktrans with expires=expires|timeuntil %} | ||||
|     If you did not request this code, please ignore this email. The code above is valid for {{ expires }}. | ||||
|     {% endblocktrans %} | ||||
|   </td> | ||||
| </tr> | ||||
| {% endblock %} | ||||
| @ -0,0 +1,13 @@ | ||||
| {% load i18n %}{% load humanize %}{% autoescape off %}{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %} | ||||
|  | ||||
| {% blocktrans %} | ||||
| Email MFA code | ||||
| {% endblocktrans %} | ||||
| {{ token }} | ||||
| {% blocktrans with expires=expires|timeuntil %} | ||||
| If you did not request this code, please ignore this email. The code above is valid for {{ expires }}. | ||||
| {% endblocktrans %} | ||||
|  | ||||
| --  | ||||
| Powered by goauthentik.io. | ||||
| {% endautoescape %} | ||||
							
								
								
									
										340
									
								
								authentik/stages/authenticator_email/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										340
									
								
								authentik/stages/authenticator_email/tests.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,340 @@ | ||||
| """Test Email Authenticator API""" | ||||
|  | ||||
| from datetime import timedelta | ||||
| from unittest.mock import MagicMock, PropertyMock, patch | ||||
|  | ||||
| from django.core import mail | ||||
| from django.core.mail.backends.smtp import EmailBackend | ||||
| from django.db.utils import IntegrityError | ||||
| from django.template.exceptions import TemplateDoesNotExist | ||||
| from django.urls import reverse | ||||
| from django.utils.timezone import now | ||||
|  | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_user | ||||
| from authentik.flows.models import FlowStageBinding | ||||
| from authentik.flows.tests import FlowTestCase | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.utils.email import mask_email | ||||
| from authentik.stages.authenticator_email.api import ( | ||||
|     AuthenticatorEmailStageSerializer, | ||||
|     EmailDeviceSerializer, | ||||
| ) | ||||
| from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice | ||||
| from authentik.stages.authenticator_email.stage import ( | ||||
|     SESSION_KEY_EMAIL_DEVICE, | ||||
| ) | ||||
| from authentik.stages.email.utils import TemplateEmailMessage | ||||
|  | ||||
|  | ||||
| class TestAuthenticatorEmailStage(FlowTestCase): | ||||
|     """Test Email Authenticator stage""" | ||||
|  | ||||
|     def setUp(self): | ||||
|         super().setUp() | ||||
|         self.flow = create_test_flow() | ||||
|         self.user = create_test_admin_user() | ||||
|         self.user_noemail = create_test_user(email="") | ||||
|         self.stage = AuthenticatorEmailStage.objects.create( | ||||
|             name="email-authenticator", | ||||
|             use_global_settings=True, | ||||
|             from_address="test@authentik.local", | ||||
|             configure_flow=self.flow, | ||||
|             token_expiry="minutes=30", | ||||
|         )  # nosec | ||||
|         self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0) | ||||
|         self.device = EmailDevice.objects.create( | ||||
|             user=self.user, | ||||
|             stage=self.stage, | ||||
|             email="test@authentik.local", | ||||
|         ) | ||||
|         self.client.force_login(self.user) | ||||
|  | ||||
|     def test_device_str(self): | ||||
|         """Test string representation of device""" | ||||
|         self.assertEqual(str(self.device), f"Email Device for {self.user.pk}") | ||||
|         # Test unsaved device | ||||
|         unsaved_device = EmailDevice( | ||||
|             user=self.user, | ||||
|             stage=self.stage, | ||||
|             email="test@authentik.local", | ||||
|         ) | ||||
|         self.assertEqual(str(unsaved_device), "New Email Device") | ||||
|  | ||||
|     def test_stage_str(self): | ||||
|         """Test string representation of stage""" | ||||
|         self.assertEqual(str(self.stage), f"Email Authenticator Stage {self.stage.name}") | ||||
|  | ||||
|     def test_token_lifecycle(self): | ||||
|         """Test token generation, validation and expiry""" | ||||
|         # Initially no token | ||||
|         self.assertIsNone(self.device.token) | ||||
|  | ||||
|         # Generate token | ||||
|         self.device.generate_token() | ||||
|         token = self.device.token | ||||
|         self.assertIsNotNone(token) | ||||
|         self.assertIsNotNone(self.device.valid_until) | ||||
|         self.assertTrue(self.device.valid_until > now()) | ||||
|  | ||||
|         # Verify invalid token | ||||
|         self.assertFalse(self.device.verify_token("000000")) | ||||
|  | ||||
|         # Verify correct token (should clear token after verification) | ||||
|         self.assertTrue(self.device.verify_token(token)) | ||||
|         self.assertIsNone(self.device.token) | ||||
|  | ||||
|     def test_stage_no_prefill(self): | ||||
|         """Test stage without prefilled email""" | ||||
|         self.client.force_login(self.user_noemail) | ||||
|         with patch( | ||||
|             "authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class", | ||||
|             PropertyMock(return_value=EmailBackend), | ||||
|         ): | ||||
|             response = self.client.get( | ||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|             ) | ||||
|             self.assertStageResponse( | ||||
|                 response, | ||||
|                 self.flow, | ||||
|                 self.user_noemail, | ||||
|                 component="ak-stage-authenticator-email", | ||||
|                 email_required=True, | ||||
|             ) | ||||
|  | ||||
|     def test_stage_submit(self): | ||||
|         """Test stage email submission""" | ||||
|         # Initialize the flow | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|         ) | ||||
|         self.assertStageResponse( | ||||
|             response, | ||||
|             self.flow, | ||||
|             self.user, | ||||
|             component="ak-stage-authenticator-email", | ||||
|             email_required=False, | ||||
|         ) | ||||
|  | ||||
|         # Test email submission with locmem backend | ||||
|         def mock_send_mails(stage, *messages): | ||||
|             """Mock send_mails to send directly""" | ||||
|             for message in messages: | ||||
|                 message.send() | ||||
|  | ||||
|         with ( | ||||
|             patch( | ||||
|                 "authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class", | ||||
|                 return_value=EmailBackend, | ||||
|             ), | ||||
|             patch( | ||||
|                 "authentik.stages.authenticator_email.stage.send_mails", | ||||
|                 side_effect=mock_send_mails, | ||||
|             ), | ||||
|         ): | ||||
|             response = self.client.post( | ||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|                 data={"component": "ak-stage-authenticator-email", "email": "test@example.com"}, | ||||
|             ) | ||||
|             self.assertEqual(response.status_code, 200) | ||||
|             self.assertEqual(len(mail.outbox), 1) | ||||
|             sent_mail = mail.outbox[0] | ||||
|             self.assertEqual(sent_mail.subject, self.stage.subject) | ||||
|             self.assertEqual(sent_mail.to, [f"{self.user} <test@example.com>"]) | ||||
|             # Get from_address from global email config to test if global settings are being used | ||||
|             from_address_global = CONFIG.get("email.from") | ||||
|             self.assertEqual(sent_mail.from_email, from_address_global) | ||||
|  | ||||
|         self.assertStageResponse( | ||||
|             response, | ||||
|             self.flow, | ||||
|             self.user, | ||||
|             component="ak-stage-authenticator-email", | ||||
|             response_errors={}, | ||||
|             email_required=False, | ||||
|         ) | ||||
|  | ||||
|     def test_email_template(self): | ||||
|         """Test email template rendering""" | ||||
|         self.device.generate_token() | ||||
|         message = self.device._compose_email() | ||||
|  | ||||
|         self.assertIsInstance(message, TemplateEmailMessage) | ||||
|         self.assertEqual(message.subject, self.stage.subject) | ||||
|         self.assertEqual(message.to, [f"{self.user.name} <{self.device.email}>"]) | ||||
|         self.assertTrue(self.device.token in message.body) | ||||
|  | ||||
|     def test_duplicate_email(self): | ||||
|         """Test attempting to use same email twice""" | ||||
|         email = "test2@authentik.local" | ||||
|         # First device | ||||
|         EmailDevice.objects.create( | ||||
|             user=self.user, | ||||
|             stage=self.stage, | ||||
|             email=email, | ||||
|         ) | ||||
|         # Attempt to create second device with same email | ||||
|         with self.assertRaises(IntegrityError): | ||||
|             EmailDevice.objects.create( | ||||
|                 user=self.user, | ||||
|                 stage=self.stage, | ||||
|                 email=email, | ||||
|             ) | ||||
|  | ||||
|     def test_token_expiry(self): | ||||
|         """Test token expiration behavior""" | ||||
|         self.device.generate_token() | ||||
|         token = self.device.token | ||||
|         # Set token as expired | ||||
|         self.device.valid_until = now() - timedelta(minutes=1) | ||||
|         self.device.save() | ||||
|         # Verify expired token fails | ||||
|         self.assertFalse(self.device.verify_token(token)) | ||||
|  | ||||
|     def test_template_errors(self): | ||||
|         """Test handling of template errors""" | ||||
|         self.stage.template = "{% invalid template %}" | ||||
|         with self.assertRaises(TemplateDoesNotExist): | ||||
|             self.stage.send(self.device) | ||||
|  | ||||
|     def test_challenge_response_validation(self): | ||||
|         """Test challenge response validation""" | ||||
|         # Initialize the flow | ||||
|         self.client.force_login(self.user_noemail) | ||||
|         with patch( | ||||
|             "authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class", | ||||
|             PropertyMock(return_value=EmailBackend), | ||||
|         ): | ||||
|             response = self.client.get( | ||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|             ) | ||||
|  | ||||
|             # Test missing code and email | ||||
|             response = self.client.post( | ||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|                 data={"component": "ak-stage-authenticator-email"}, | ||||
|             ) | ||||
|             self.assertIn("email required", str(response.content)) | ||||
|  | ||||
|             # Test invalid code | ||||
|             response = self.client.post( | ||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|                 data={"component": "ak-stage-authenticator-email", "code": "000000"}, | ||||
|             ) | ||||
|             self.assertIn("Code does not match", str(response.content)) | ||||
|  | ||||
|             # Test valid code | ||||
|             self.client.force_login(self.user) | ||||
|             response = self.client.get( | ||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|             ) | ||||
|             device = self.device | ||||
|             token = device.token | ||||
|             response = self.client.post( | ||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|                 data={"component": "ak-stage-authenticator-email", "code": token}, | ||||
|             ) | ||||
|             self.assertEqual(response.status_code, 200) | ||||
|             self.assertTrue(device.confirmed) | ||||
|  | ||||
|     def test_challenge_generation(self): | ||||
|         """Test challenge generation""" | ||||
|         # Test with masked email | ||||
|         with patch( | ||||
|             "authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class", | ||||
|             PropertyMock(return_value=EmailBackend), | ||||
|         ): | ||||
|             response = self.client.get( | ||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|             ) | ||||
|             self.assertStageResponse( | ||||
|                 response, | ||||
|                 self.flow, | ||||
|                 self.user, | ||||
|                 component="ak-stage-authenticator-email", | ||||
|                 email_required=False, | ||||
|             ) | ||||
|             masked_email = mask_email(self.user.email) | ||||
|             self.assertEqual(masked_email, response.json()["email"]) | ||||
|  | ||||
|             # Test without email | ||||
|             self.client.force_login(self.user_noemail) | ||||
|             response = self.client.get( | ||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|             ) | ||||
|             self.assertStageResponse( | ||||
|                 response, | ||||
|                 self.flow, | ||||
|                 self.user_noemail, | ||||
|                 component="ak-stage-authenticator-email", | ||||
|                 email_required=True, | ||||
|             ) | ||||
|             self.assertIsNone(response.json()["email"]) | ||||
|  | ||||
|     def test_session_management(self): | ||||
|         """Test session device management""" | ||||
|         # Test device creation in session | ||||
|         with patch( | ||||
|             "authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class", | ||||
|             PropertyMock(return_value=EmailBackend), | ||||
|         ): | ||||
|             # Delete any existing devices for this test | ||||
|             EmailDevice.objects.filter(user=self.user).delete() | ||||
|  | ||||
|             response = self.client.get( | ||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|             ) | ||||
|             self.assertIn(SESSION_KEY_EMAIL_DEVICE, self.client.session) | ||||
|             device = self.client.session[SESSION_KEY_EMAIL_DEVICE] | ||||
|             self.assertIsInstance(device, EmailDevice) | ||||
|             self.assertFalse(device.confirmed) | ||||
|             self.assertEqual(device.user, self.user) | ||||
|  | ||||
|             # Test device confirmation and cleanup | ||||
|             device.confirmed = True | ||||
|             device.email = "new_test@authentik.local"  # Use a different email | ||||
|             self.client.session[SESSION_KEY_EMAIL_DEVICE] = device | ||||
|             self.client.session.save() | ||||
|             response = self.client.post( | ||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|                 data={"component": "ak-stage-authenticator-email", "code": device.token}, | ||||
|             ) | ||||
|             self.assertEqual(response.status_code, 200) | ||||
|             self.assertTrue(device.confirmed) | ||||
|             # Session key should be removed after device is saved | ||||
|             device.save() | ||||
|             self.assertNotIn(SESSION_KEY_EMAIL_DEVICE, self.client.session) | ||||
|  | ||||
|     def test_model_properties_and_methods(self): | ||||
|         """Test model properties""" | ||||
|         device = self.device | ||||
|         stage = self.stage | ||||
|  | ||||
|         self.assertEqual(stage.serializer, AuthenticatorEmailStageSerializer) | ||||
|         self.assertIsInstance(stage.backend, EmailBackend) | ||||
|         self.assertEqual(device.serializer, EmailDeviceSerializer) | ||||
|  | ||||
|         # Test AuthenticatorEmailStage send method | ||||
|         with patch( | ||||
|             "authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class", | ||||
|             return_value=EmailBackend, | ||||
|         ): | ||||
|             self.device.generate_token() | ||||
|             # Test EmailDevice _compose_email method | ||||
|             message = self.device._compose_email() | ||||
|             self.assertIsInstance(message, TemplateEmailMessage) | ||||
|             self.assertEqual(message.subject, self.stage.subject) | ||||
|             self.assertEqual(message.to, [f"{self.user.name} <{self.device.email}>"]) | ||||
|             self.assertTrue(self.device.token in message.body) | ||||
|             # Test AuthenticatorEmailStage send method | ||||
|             self.stage.send(device) | ||||
|  | ||||
|     def test_email_tasks(self): | ||||
|  | ||||
|         email_send_mock = MagicMock() | ||||
|         with patch( | ||||
|             "authentik.stages.email.tasks.send_mails", | ||||
|             email_send_mock, | ||||
|         ): | ||||
|             # Test AuthenticatorEmailStage send method | ||||
|             self.stage.send(self.device) | ||||
|             email_send_mock.assert_called_once() | ||||
							
								
								
									
										17
									
								
								authentik/stages/authenticator_email/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								authentik/stages/authenticator_email/urls.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,17 @@ | ||||
| """API URLs""" | ||||
|  | ||||
| from authentik.stages.authenticator_email.api import ( | ||||
|     AuthenticatorEmailStageViewSet, | ||||
|     EmailAdminDeviceViewSet, | ||||
|     EmailDeviceViewSet, | ||||
| ) | ||||
|  | ||||
| api_urlpatterns = [ | ||||
|     ("authenticators/email", EmailDeviceViewSet), | ||||
|     ( | ||||
|         "authenticators/admin/email", | ||||
|         EmailAdminDeviceViewSet, | ||||
|         "admin-emaildevice", | ||||
|     ), | ||||
|     ("stages/authenticator/email", AuthenticatorEmailStageViewSet), | ||||
| ] | ||||
| @ -26,10 +26,13 @@ from authentik.events.middleware import audit_ignore | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.flows.stage import StageView | ||||
| from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE | ||||
| from authentik.lib.utils.email import mask_email | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.root.middleware import ClientIPMiddleware | ||||
| from authentik.stages.authenticator import match_token | ||||
| from authentik.stages.authenticator.models import Device | ||||
| from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice | ||||
| from authentik.stages.authenticator_email.models import EmailDevice | ||||
| from authentik.stages.authenticator_sms.models import SMSDevice | ||||
| from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses | ||||
| from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice | ||||
| @ -54,6 +57,8 @@ def get_challenge_for_device( | ||||
|     """Generate challenge for a single device""" | ||||
|     if isinstance(device, WebAuthnDevice): | ||||
|         return get_webauthn_challenge(request, stage, device) | ||||
|     if isinstance(device, EmailDevice): | ||||
|         return {"email": mask_email(device.email)} | ||||
|     # Code-based challenges have no hints | ||||
|     return {} | ||||
|  | ||||
| @ -103,6 +108,8 @@ def select_challenge(request: HttpRequest, device: Device): | ||||
|     """Callback when the user selected a challenge in the frontend.""" | ||||
|     if isinstance(device, SMSDevice): | ||||
|         select_challenge_sms(request, device) | ||||
|     elif isinstance(device, EmailDevice): | ||||
|         select_challenge_email(request, device) | ||||
|  | ||||
|  | ||||
| def select_challenge_sms(request: HttpRequest, device: SMSDevice): | ||||
| @ -111,6 +118,13 @@ def select_challenge_sms(request: HttpRequest, device: SMSDevice): | ||||
|     device.stage.send(device.token, device) | ||||
|  | ||||
|  | ||||
| def select_challenge_email(request: HttpRequest, device: EmailDevice): | ||||
|     """Send Email""" | ||||
|     valid_secs: int = timedelta_from_string(device.stage.token_expiry).total_seconds() | ||||
|     device.generate_token(valid_secs=valid_secs) | ||||
|     device.stage.send(device) | ||||
|  | ||||
|  | ||||
| def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Device: | ||||
|     """Validate code-based challenges. We test against every device, on purpose, as | ||||
|     the user mustn't choose between totp and static devices.""" | ||||
|  | ||||
| @ -0,0 +1,37 @@ | ||||
| # Generated by Django 5.0.10 on 2025-01-16 02:48 | ||||
|  | ||||
| import authentik.stages.authenticator_validate.models | ||||
| import django.contrib.postgres.fields | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ( | ||||
|             "authentik_stages_authenticator_validate", | ||||
|             "0013_authenticatorvalidatestage_webauthn_allowed_device_types", | ||||
|         ), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="authenticatorvalidatestage", | ||||
|             name="device_classes", | ||||
|             field=django.contrib.postgres.fields.ArrayField( | ||||
|                 base_field=models.TextField( | ||||
|                     choices=[ | ||||
|                         ("static", "Static"), | ||||
|                         ("totp", "TOTP"), | ||||
|                         ("webauthn", "WebAuthn"), | ||||
|                         ("duo", "Duo"), | ||||
|                         ("sms", "SMS"), | ||||
|                         ("email", "Email"), | ||||
|                     ] | ||||
|                 ), | ||||
|                 default=authentik.stages.authenticator_validate.models.default_device_classes, | ||||
|                 help_text="Device classes which can be used to authenticate", | ||||
|                 size=None, | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -20,6 +20,7 @@ class DeviceClasses(models.TextChoices): | ||||
|     WEBAUTHN = "webauthn", _("WebAuthn") | ||||
|     DUO = "duo", _("Duo") | ||||
|     SMS = "sms", _("SMS") | ||||
|     EMAIL = "email", _("Email") | ||||
|  | ||||
|  | ||||
| def default_device_classes() -> list: | ||||
| @ -30,6 +31,7 @@ def default_device_classes() -> list: | ||||
|         DeviceClasses.WEBAUTHN, | ||||
|         DeviceClasses.DUO, | ||||
|         DeviceClasses.SMS, | ||||
|         DeviceClasses.EMAIL, | ||||
|     ] | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -23,6 +23,7 @@ from authentik.flows.stage import ChallengeStageView | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.stages.authenticator import devices_for_user | ||||
| from authentik.stages.authenticator.models import Device | ||||
| from authentik.stages.authenticator_email.models import EmailDevice | ||||
| from authentik.stages.authenticator_sms.models import SMSDevice | ||||
| from authentik.stages.authenticator_validate.challenge import ( | ||||
|     DeviceChallenge, | ||||
| @ -84,7 +85,9 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): | ||||
|  | ||||
|     def validate_code(self, code: str) -> str: | ||||
|         """Validate code-based response, raise error if code isn't allowed""" | ||||
|         self._challenge_allowed([DeviceClasses.TOTP, DeviceClasses.STATIC, DeviceClasses.SMS]) | ||||
|         self._challenge_allowed( | ||||
|             [DeviceClasses.TOTP, DeviceClasses.STATIC, DeviceClasses.SMS, DeviceClasses.EMAIL] | ||||
|         ) | ||||
|         self.device = validate_challenge_code(code, self.stage, self.stage.get_pending_user()) | ||||
|         return code | ||||
|  | ||||
| @ -117,12 +120,17 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): | ||||
|         if not allowed: | ||||
|             raise ValidationError("invalid challenge selected") | ||||
|  | ||||
|         if challenge.get("device_class", "") != "sms": | ||||
|             return challenge | ||||
|         devices = SMSDevice.objects.filter(pk=int(challenge.get("device_uid", "0"))) | ||||
|         if not devices.exists(): | ||||
|             raise ValidationError("invalid challenge selected") | ||||
|         select_challenge(self.stage.request, devices.first()) | ||||
|         device_class = challenge.get("device_class", "") | ||||
|         if device_class == "sms": | ||||
|             devices = SMSDevice.objects.filter(pk=int(challenge.get("device_uid", "0"))) | ||||
|             if not devices.exists(): | ||||
|                 raise ValidationError("invalid challenge selected") | ||||
|             select_challenge(self.stage.request, devices.first()) | ||||
|         elif device_class == "email": | ||||
|             devices = EmailDevice.objects.filter(pk=int(challenge.get("device_uid", "0"))) | ||||
|             if not devices.exists(): | ||||
|                 raise ValidationError("invalid challenge selected") | ||||
|             select_challenge(self.stage.request, devices.first()) | ||||
|         return challenge | ||||
|  | ||||
|     def validate_selected_stage(self, stage_pk: str) -> str: | ||||
|  | ||||
							
								
								
									
										183
									
								
								authentik/stages/authenticator_validate/tests/test_email.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								authentik/stages/authenticator_validate/tests/test_email.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,183 @@ | ||||
| """Test validator stage for Email devices""" | ||||
|  | ||||
| from django.test.client import RequestFactory | ||||
| from django.urls.base import reverse | ||||
|  | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow | ||||
| from authentik.flows.models import FlowStageBinding, NotConfiguredAction | ||||
| from authentik.flows.tests import FlowTestCase | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.lib.utils.email import mask_email | ||||
| from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice | ||||
| from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses | ||||
| from authentik.stages.identification.models import IdentificationStage, UserFields | ||||
|  | ||||
|  | ||||
| class AuthenticatorValidateStageEmailTests(FlowTestCase): | ||||
|     """Test validator stage for Email devices""" | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         self.user = create_test_admin_user() | ||||
|         self.request_factory = RequestFactory() | ||||
|         # Create email authenticator stage | ||||
|         self.stage = AuthenticatorEmailStage.objects.create( | ||||
|             name="email-authenticator", | ||||
|             use_global_settings=True, | ||||
|             from_address="test@authentik.local", | ||||
|         ) | ||||
|         # Create identification stage | ||||
|         self.ident_stage = IdentificationStage.objects.create( | ||||
|             name=generate_id(), | ||||
|             user_fields=[UserFields.USERNAME], | ||||
|         ) | ||||
|         # Create validation stage | ||||
|         self.validate_stage = AuthenticatorValidateStage.objects.create( | ||||
|             name=generate_id(), | ||||
|             device_classes=[DeviceClasses.EMAIL], | ||||
|         ) | ||||
|         # Create flow with both stages | ||||
|         self.flow = create_test_flow() | ||||
|         FlowStageBinding.objects.create(target=self.flow, stage=self.ident_stage, order=0) | ||||
|         FlowStageBinding.objects.create(target=self.flow, stage=self.validate_stage, order=1) | ||||
|  | ||||
|     def _identify_user(self): | ||||
|         """Helper to identify user in flow""" | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|             {"uid_field": self.user.username}, | ||||
|             follow=True, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         return response | ||||
|  | ||||
|     def _send_challenge(self, device): | ||||
|         """Helper to send challenge for device""" | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|             { | ||||
|                 "component": "ak-stage-authenticator-validate", | ||||
|                 "selected_challenge": { | ||||
|                     "device_class": "email", | ||||
|                     "device_uid": str(device.pk), | ||||
|                     "challenge": {}, | ||||
|                     "last_used": device.last_used.isoformat() if device.last_used else None, | ||||
|                 }, | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         return response | ||||
|  | ||||
|     def test_happy_path(self): | ||||
|         """Test validator stage with valid code""" | ||||
|         # Create a device for our user | ||||
|         device = EmailDevice.objects.create( | ||||
|             user=self.user, | ||||
|             confirmed=True, | ||||
|             stage=self.stage, | ||||
|             email="xx@0.co", | ||||
|         )  # Short email for testing purposes | ||||
|  | ||||
|         # First identify the user | ||||
|         self._identify_user() | ||||
|  | ||||
|         # Send the challenge | ||||
|         response = self._send_challenge(device) | ||||
|         response_data = self.assertStageResponse( | ||||
|             response, | ||||
|             flow=self.flow, | ||||
|             component="ak-stage-authenticator-validate", | ||||
|         ) | ||||
|  | ||||
|         # Get the device challenge from the response and verify it matches | ||||
|         device_challenge = response_data["device_challenges"][0] | ||||
|         self.assertEqual(device_challenge["device_class"], "email") | ||||
|         self.assertEqual(device_challenge["device_uid"], str(device.pk)) | ||||
|         self.assertEqual(device_challenge["challenge"], {"email": mask_email(device.email)}) | ||||
|  | ||||
|         # Generate a token for the device | ||||
|         device.generate_token() | ||||
|  | ||||
|         # Submit the valid code | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|             {"component": "ak-stage-authenticator-validate", "code": device.token}, | ||||
|         ) | ||||
|         # Should redirect to root since this is the last stage | ||||
|         self.assertStageRedirects(response, "/") | ||||
|  | ||||
|     def test_no_device(self): | ||||
|         """Test validator stage without configured device""" | ||||
|         configuration_stage = AuthenticatorEmailStage.objects.create( | ||||
|             name=generate_id(), | ||||
|             use_global_settings=True, | ||||
|             from_address="test@authentik.local", | ||||
|         ) | ||||
|         stage = AuthenticatorValidateStage.objects.create( | ||||
|             name=generate_id(), | ||||
|             not_configured_action=NotConfiguredAction.CONFIGURE, | ||||
|             device_classes=[DeviceClasses.EMAIL], | ||||
|         ) | ||||
|         stage.configuration_stages.set([configuration_stage]) | ||||
|         flow = create_test_flow() | ||||
|         FlowStageBinding.objects.create(target=flow, stage=stage, order=2) | ||||
|  | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||
|             {"component": "ak-stage-authenticator-validate"}, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         response_data = self.assertStageResponse( | ||||
|             response, | ||||
|             flow=flow, | ||||
|             component="ak-stage-authenticator-validate", | ||||
|         ) | ||||
|         self.assertEqual(response_data["configuration_stages"], []) | ||||
|         self.assertEqual(response_data["device_challenges"], []) | ||||
|         self.assertEqual( | ||||
|             response_data["response_errors"], | ||||
|             {"non_field_errors": [{"code": "invalid", "string": "Empty response"}]}, | ||||
|         ) | ||||
|  | ||||
|     def test_invalid_code(self): | ||||
|         """Test validator stage with invalid code""" | ||||
|         # Create a device for our user | ||||
|         device = EmailDevice.objects.create( | ||||
|             user=self.user, | ||||
|             confirmed=True, | ||||
|             stage=self.stage, | ||||
|             email="test@authentik.local", | ||||
|         ) | ||||
|  | ||||
|         # First identify the user | ||||
|         self._identify_user() | ||||
|  | ||||
|         # Send the challenge | ||||
|         self._send_challenge(device) | ||||
|  | ||||
|         # Generate a token for the device | ||||
|         device.generate_token() | ||||
|  | ||||
|         # Try invalid code and verify error message | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|             {"component": "ak-stage-authenticator-validate", "code": "invalid"}, | ||||
|         ) | ||||
|         response_data = self.assertStageResponse( | ||||
|             response, | ||||
|             flow=self.flow, | ||||
|             component="ak-stage-authenticator-validate", | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             response_data["response_errors"], | ||||
|             { | ||||
|                 "code": [ | ||||
|                     { | ||||
|                         "code": "invalid", | ||||
|                         "string": ( | ||||
|                             "Invalid Token. Please ensure the time on your device " | ||||
|                             "is accurate and try again." | ||||
|                         ), | ||||
|                     } | ||||
|                 ], | ||||
|             }, | ||||
|         ) | ||||
| @ -13,17 +13,28 @@ from structlog.stdlib import get_logger | ||||
| from authentik.events.models import Event, EventAction, TaskStatus | ||||
| from authentik.events.system_tasks import SystemTask | ||||
| from authentik.root.celery import CELERY_APP | ||||
| from authentik.stages.authenticator_email.models import AuthenticatorEmailStage | ||||
| from authentik.stages.email.models import EmailStage | ||||
| from authentik.stages.email.utils import logo_data | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| def send_mails(stage: EmailStage, *messages: list[EmailMultiAlternatives]): | ||||
|     """Wrapper to convert EmailMessage to dict and send it from worker""" | ||||
| def send_mails( | ||||
|     stage: EmailStage | AuthenticatorEmailStage, *messages: list[EmailMultiAlternatives] | ||||
| ): | ||||
|     """Wrapper to convert EmailMessage to dict and send it from worker | ||||
|  | ||||
|     Args: | ||||
|         stage: Either an EmailStage or AuthenticatorEmailStage instance | ||||
|         messages: List of email messages to send | ||||
|     Returns: | ||||
|         Celery group promise for the email sending tasks | ||||
|     """ | ||||
|     tasks = [] | ||||
|     stage_class = stage.__class__ | ||||
|     for message in messages: | ||||
|         tasks.append(send_mail.s(message.__dict__, str(stage.pk))) | ||||
|         tasks.append(send_mail.s(message.__dict__, stage_class, str(stage.pk))) | ||||
|     lazy_group = group(*tasks) | ||||
|     promise = lazy_group() | ||||
|     return promise | ||||
| @ -47,23 +58,28 @@ def get_email_body(email: EmailMultiAlternatives) -> str: | ||||
|     retry_backoff=True, | ||||
|     base=SystemTask, | ||||
| ) | ||||
| def send_mail(self: SystemTask, message: dict[Any, Any], email_stage_pk: str | None = None): | ||||
| def send_mail( | ||||
|     self: SystemTask, | ||||
|     message: dict[Any, Any], | ||||
|     stage_class: EmailStage | AuthenticatorEmailStage = EmailStage, | ||||
|     email_stage_pk: str | None = None, | ||||
| ): | ||||
|     """Send Email for Email Stage. Retries are scheduled automatically.""" | ||||
|     self.save_on_success = False | ||||
|     message_id = make_msgid(domain=DNS_NAME) | ||||
|     self.set_uid(slugify(message_id.replace(".", "_").replace("@", "_"))) | ||||
|     try: | ||||
|         if not email_stage_pk: | ||||
|             stage: EmailStage = EmailStage(use_global_settings=True) | ||||
|             stage: EmailStage | AuthenticatorEmailStage = stage_class(use_global_settings=True) | ||||
|         else: | ||||
|             stages = EmailStage.objects.filter(pk=email_stage_pk) | ||||
|             stages = stage_class.objects.filter(pk=email_stage_pk) | ||||
|             if not stages.exists(): | ||||
|                 self.set_status( | ||||
|                     TaskStatus.WARNING, | ||||
|                     "Email stage does not exist anymore. Discarding message.", | ||||
|                 ) | ||||
|                 return | ||||
|             stage: EmailStage = stages.first() | ||||
|             stage: EmailStage | AuthenticatorEmailStage = stages.first() | ||||
|         try: | ||||
|             backend = stage.backend | ||||
|         except ValueError as exc: | ||||
|  | ||||
| @ -1,14 +1,18 @@ | ||||
| """Tenant-aware Celery beat scheduler""" | ||||
|  | ||||
| from tenant_schemas_celery.scheduler import ( | ||||
|     TenantAwarePersistentScheduler as BaseTenantAwarePersistentScheduler, | ||||
| ) | ||||
| from tenant_schemas_celery.scheduler import TenantAwareScheduleEntry | ||||
| from django_celery_beat.schedulers import DatabaseScheduler, ModelEntry | ||||
| from tenant_schemas_celery.scheduler import TenantAwareScheduleEntry, TenantAwareSchedulerMixin | ||||
|  | ||||
|  | ||||
| class TenantAwarePersistentScheduler(BaseTenantAwarePersistentScheduler): | ||||
| class SchedulerEntry(ModelEntry, TenantAwareScheduleEntry): | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class TenantAwarePersistentScheduler(TenantAwareSchedulerMixin, DatabaseScheduler): | ||||
|     """Tenant-aware Celery beat scheduler""" | ||||
|  | ||||
|     Entry = SchedulerEntry | ||||
|  | ||||
|     @classmethod | ||||
|     def get_queryset(cls): | ||||
|         return super().get_queryset().filter(ready=True) | ||||
|  | ||||
							
								
								
									
										30
									
								
								blueprints/example/flows-authenticator-email-setup.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								blueprints/example/flows-authenticator-email-setup.yaml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,30 @@ | ||||
| version: 1 | ||||
| metadata: | ||||
|   labels: | ||||
|     blueprints.goauthentik.io/instantiate: "false" | ||||
|   name: Example - Email MFA setup flow | ||||
| entries: | ||||
| - attrs: | ||||
|     designation: stage_configuration | ||||
|     name: Default Email Authenticator Flow | ||||
|     title: Setup Email Two-Factor Authentication | ||||
|     authentication: require_authenticated | ||||
|   identifiers: | ||||
|     slug: default-authenticator-email-setup | ||||
|   model: authentik_flows.flow | ||||
|   id: flow | ||||
| - attrs: | ||||
|     configure_flow: !KeyOf flow | ||||
|     friendly_name: Email Authenticator | ||||
|     use_global_settings: true | ||||
|     token_expiry: minutes=30 | ||||
|     subject: authentik Sign-in code | ||||
|   identifiers: | ||||
|     name: default-authenticator-email-setup | ||||
|   id: default-authenticator-email-setup | ||||
|   model: authentik_stages_authenticator_email.authenticatoremailstage | ||||
| - identifiers: | ||||
|     order: 0 | ||||
|     stage: !KeyOf default-authenticator-email-setup | ||||
|     target: !KeyOf flow | ||||
|   model: authentik_flows.flowstagebinding | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -10,6 +10,7 @@ import ( | ||||
|  | ||||
| 	"goauthentik.io/internal/common" | ||||
| 	"goauthentik.io/internal/config" | ||||
| 	"goauthentik.io/internal/constants" | ||||
| 	"goauthentik.io/internal/debug" | ||||
| 	"goauthentik.io/internal/outpost/ak" | ||||
| 	"goauthentik.io/internal/outpost/ak/healthcheck" | ||||
| @ -24,7 +25,8 @@ Required environment variables: | ||||
| - AUTHENTIK_INSECURE: Skip SSL Certificate verification` | ||||
|  | ||||
| var rootCmd = &cobra.Command{ | ||||
| 	Long: helpMessage, | ||||
| 	Long:    helpMessage, | ||||
| 	Version: constants.FullVersion(), | ||||
| 	PersistentPreRun: func(cmd *cobra.Command, args []string) { | ||||
| 		log.SetLevel(log.DebugLevel) | ||||
| 		log.SetFormatter(&log.JSONFormatter{ | ||||
|  | ||||
| @ -10,6 +10,7 @@ import ( | ||||
|  | ||||
| 	"goauthentik.io/internal/common" | ||||
| 	"goauthentik.io/internal/config" | ||||
| 	"goauthentik.io/internal/constants" | ||||
| 	"goauthentik.io/internal/debug" | ||||
| 	"goauthentik.io/internal/outpost/ak" | ||||
| 	"goauthentik.io/internal/outpost/ak/healthcheck" | ||||
| @ -27,7 +28,8 @@ Optionally, you can set these: | ||||
| - AUTHENTIK_HOST_BROWSER: URL to use in the browser, when it differs from AUTHENTIK_HOST` | ||||
|  | ||||
| var rootCmd = &cobra.Command{ | ||||
| 	Long: helpMessage, | ||||
| 	Long:    helpMessage, | ||||
| 	Version: constants.FullVersion(), | ||||
| 	PersistentPreRun: func(cmd *cobra.Command, args []string) { | ||||
| 		log.SetLevel(log.DebugLevel) | ||||
| 		log.SetFormatter(&log.JSONFormatter{ | ||||
|  | ||||
| @ -9,6 +9,7 @@ import ( | ||||
| 	"github.com/spf13/cobra" | ||||
|  | ||||
| 	"goauthentik.io/internal/common" | ||||
| 	"goauthentik.io/internal/constants" | ||||
| 	"goauthentik.io/internal/debug" | ||||
| 	"goauthentik.io/internal/outpost/ak" | ||||
| 	"goauthentik.io/internal/outpost/ak/healthcheck" | ||||
| @ -23,7 +24,8 @@ Required environment variables: | ||||
| - AUTHENTIK_INSECURE: Skip SSL Certificate verification` | ||||
|  | ||||
| var rootCmd = &cobra.Command{ | ||||
| 	Long: helpMessage, | ||||
| 	Long:    helpMessage, | ||||
| 	Version: constants.FullVersion(), | ||||
| 	PersistentPreRun: func(cmd *cobra.Command, args []string) { | ||||
| 		log.SetLevel(log.DebugLevel) | ||||
| 		log.SetFormatter(&log.JSONFormatter{ | ||||
|  | ||||
| @ -9,6 +9,7 @@ import ( | ||||
| 	"github.com/spf13/cobra" | ||||
|  | ||||
| 	"goauthentik.io/internal/common" | ||||
| 	"goauthentik.io/internal/constants" | ||||
| 	"goauthentik.io/internal/debug" | ||||
| 	"goauthentik.io/internal/outpost/ak" | ||||
| 	"goauthentik.io/internal/outpost/ak/healthcheck" | ||||
| @ -23,7 +24,8 @@ Required environment variables: | ||||
| - AUTHENTIK_INSECURE: Skip SSL Certificate verification` | ||||
|  | ||||
| var rootCmd = &cobra.Command{ | ||||
| 	Long: helpMessage, | ||||
| 	Long:    helpMessage, | ||||
| 	Version: constants.FullVersion(), | ||||
| 	PersistentPreRun: func(cmd *cobra.Command, args []string) { | ||||
| 		log.SetLevel(log.DebugLevel) | ||||
| 		log.SetFormatter(&log.JSONFormatter{ | ||||
|  | ||||
							
								
								
									
										185
									
								
								cmd/scim/main.go
									
									
									
									
									
								
							
							
						
						
									
										185
									
								
								cmd/scim/main.go
									
									
									
									
									
								
							| @ -1,185 +0,0 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"crypto/tls" | ||||
| 	"encoding/base64" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"os" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/mitchellh/mapstructure" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"github.com/spf13/cobra" | ||||
|  | ||||
| 	"goauthentik.io/internal/common" | ||||
| 	"goauthentik.io/internal/debug" | ||||
| 	"goauthentik.io/internal/outpost/ak" | ||||
| 	"goauthentik.io/internal/outpost/ak/healthcheck" | ||||
| ) | ||||
|  | ||||
| const helpMessage = `authentik SCIM | ||||
|  | ||||
| Required environment variables: | ||||
| - AUTHENTIK_HOST: URL to connect to (format "http://authentik.company") | ||||
| - AUTHENTIK_TOKEN: Token to authenticate with | ||||
| - AUTHENTIK_INSECURE: Skip SSL Certificate verification` | ||||
|  | ||||
| var rootCmd = &cobra.Command{ | ||||
| 	Long: helpMessage, | ||||
| 	PersistentPreRun: func(cmd *cobra.Command, args []string) { | ||||
| 		log.SetLevel(log.DebugLevel) | ||||
| 		log.SetFormatter(&log.JSONFormatter{ | ||||
| 			FieldMap: log.FieldMap{ | ||||
| 				log.FieldKeyMsg:  "event", | ||||
| 				log.FieldKeyTime: "timestamp", | ||||
| 			}, | ||||
| 			DisableHTMLEscape: true, | ||||
| 		}) | ||||
| 	}, | ||||
| 	Run: func(cmd *cobra.Command, args []string) { | ||||
| 		debug.EnableDebugServer() | ||||
| 		akURL, found := os.LookupEnv("AUTHENTIK_HOST") | ||||
| 		if !found { | ||||
| 			fmt.Println("env AUTHENTIK_HOST not set!") | ||||
| 			fmt.Println(helpMessage) | ||||
| 			os.Exit(1) | ||||
| 		} | ||||
| 		akToken, found := os.LookupEnv("AUTHENTIK_TOKEN") | ||||
| 		if !found { | ||||
| 			fmt.Println("env AUTHENTIK_TOKEN not set!") | ||||
| 			fmt.Println(helpMessage) | ||||
| 			os.Exit(1) | ||||
| 		} | ||||
|  | ||||
| 		akURLActual, err := url.Parse(akURL) | ||||
| 		if err != nil { | ||||
| 			fmt.Println(err) | ||||
| 			fmt.Println(helpMessage) | ||||
| 			os.Exit(1) | ||||
| 		} | ||||
|  | ||||
| 		ex := common.Init() | ||||
| 		defer common.Defer() | ||||
| 		go func() { | ||||
| 			for { | ||||
| 				<-ex | ||||
| 				os.Exit(0) | ||||
| 			} | ||||
| 		}() | ||||
|  | ||||
| 		ac := ak.NewAPIController(*akURLActual, akToken) | ||||
| 		if ac == nil { | ||||
| 			os.Exit(1) | ||||
| 		} | ||||
| 		defer ac.Shutdown() | ||||
|  | ||||
| 		ac.Server = &SCIMOutpost{ | ||||
| 			ac:  ac, | ||||
| 			log: log.WithField("logger", "authentik.outpost.scim"), | ||||
| 		} | ||||
|  | ||||
| 		err = ac.Start() | ||||
| 		if err != nil { | ||||
| 			log.WithError(err).Panic("Failed to run server") | ||||
| 		} | ||||
|  | ||||
| 		for { | ||||
| 			<-ex | ||||
| 		} | ||||
| 	}, | ||||
| } | ||||
|  | ||||
| type HTTPRequest struct { | ||||
| 	Uid       string              `mapstructure:"uid"` | ||||
| 	Method    string              `mapstructure:"method"` | ||||
| 	URL       string              `mapstructure:"url"` | ||||
| 	Headers   map[string][]string `mapstructure:"headers"` | ||||
| 	Body      interface{}         `mapstructure:"body"` | ||||
| 	SSLVerify bool                `mapstructure:"ssl_verify"` | ||||
| 	Timeout   int                 `mapstructure:"timeout"` | ||||
| } | ||||
|  | ||||
| type RequestArgs struct { | ||||
| 	Request         HTTPRequest `mapstructure:"request"` | ||||
| 	ResponseChannel string      `mapstructure:"response_channel"` | ||||
| } | ||||
|  | ||||
| type SCIMOutpost struct { | ||||
| 	ac  *ak.APIController | ||||
| 	log *log.Entry | ||||
| } | ||||
|  | ||||
| func (s *SCIMOutpost) Type() string                         { return "SCIM" } | ||||
| func (s *SCIMOutpost) Stop() error                          { return nil } | ||||
| func (s *SCIMOutpost) Refresh() error                       { return nil } | ||||
| func (s *SCIMOutpost) TimerFlowCacheExpiry(context.Context) {} | ||||
|  | ||||
| func (s *SCIMOutpost) Start() error { | ||||
| 	s.ac.AddWSHandler(func(ctx context.Context, args map[string]interface{}) { | ||||
| 		rd := RequestArgs{} | ||||
| 		err := mapstructure.Decode(args, &rd) | ||||
| 		if err != nil { | ||||
| 			s.log.WithError(err).Warning("failed to parse http request") | ||||
| 			return | ||||
| 		} | ||||
| 		s.log.WithField("rd", rd).WithField("raw", args).Debug("request data") | ||||
| 		ctx, canc := context.WithTimeout(ctx, time.Duration(rd.Request.Timeout)*time.Second) | ||||
| 		defer canc() | ||||
|  | ||||
| 		req, err := http.NewRequestWithContext(ctx, rd.Request.Method, rd.Request.URL, nil) | ||||
| 		if err != nil { | ||||
| 			s.log.WithError(err).Warning("failed to create request") | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		tr := &http.Transport{ | ||||
| 			TLSClientConfig:       &tls.Config{InsecureSkipVerify: !rd.Request.SSLVerify}, | ||||
| 			TLSHandshakeTimeout:   time.Duration(rd.Request.Timeout) * time.Second, | ||||
| 			IdleConnTimeout:       time.Duration(rd.Request.Timeout) * time.Second, | ||||
| 			ResponseHeaderTimeout: time.Duration(rd.Request.Timeout) * time.Second, | ||||
| 			ExpectContinueTimeout: time.Duration(rd.Request.Timeout) * time.Second, | ||||
| 		} | ||||
| 		c := &http.Client{ | ||||
| 			Transport: tr, | ||||
| 		} | ||||
| 		s.log.WithField("url", req.URL.Host).Debug("sending HTTP request") | ||||
| 		res, err := c.Do(req) | ||||
| 		if err != nil { | ||||
| 			s.log.WithError(err).Warning("failed to send request") | ||||
| 			return | ||||
| 		} | ||||
| 		body, err := io.ReadAll(res.Body) | ||||
| 		if err != nil { | ||||
| 			s.log.WithError(err).Warning("failed to read body") | ||||
| 			return | ||||
| 		} | ||||
| 		s.log.WithField("res", res.StatusCode).Debug("sending HTTP response") | ||||
| 		err = s.ac.SendWS(ak.WebsocketInstructionProviderSpecific, map[string]interface{}{ | ||||
| 			"sub_type":         "http_response", | ||||
| 			"response_channel": rd.ResponseChannel, | ||||
| 			"response": map[string]interface{}{ | ||||
| 				"status":    res.StatusCode, | ||||
| 				"final_url": res.Request.URL.String(), | ||||
| 				"headers":   res.Header, | ||||
| 				"body":      base64.StdEncoding.EncodeToString(body), | ||||
| 			}, | ||||
| 		}) | ||||
| 		if err != nil { | ||||
| 			s.log.WithError(err).Warning("failed to send http response") | ||||
| 			return | ||||
| 		} | ||||
| 	}) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func main() { | ||||
| 	rootCmd.AddCommand(healthcheck.Command) | ||||
| 	err := rootCmd.Execute() | ||||
| 	if err != nil { | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										14
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								go.mod
									
									
									
									
									
								
							| @ -22,14 +22,14 @@ require ( | ||||
| 	github.com/mitchellh/mapstructure v1.5.0 | ||||
| 	github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 | ||||
| 	github.com/pires/go-proxyproto v0.8.0 | ||||
| 	github.com/prometheus/client_golang v1.20.5 | ||||
| 	github.com/prometheus/client_golang v1.21.0 | ||||
| 	github.com/redis/go-redis/v9 v9.7.0 | ||||
| 	github.com/sethvargo/go-envconfig v1.1.1 | ||||
| 	github.com/sirupsen/logrus v1.9.3 | ||||
| 	github.com/spf13/cobra v1.8.1 | ||||
| 	github.com/spf13/cobra v1.9.1 | ||||
| 	github.com/stretchr/testify v1.10.0 | ||||
| 	github.com/wwt/guac v1.3.2 | ||||
| 	goauthentik.io/api/v3 v3.2024123.4 | ||||
| 	goauthentik.io/api/v3 v3.2024123.7 | ||||
| 	golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab | ||||
| 	golang.org/x/oauth2 v0.26.0 | ||||
| 	golang.org/x/sync v0.11.0 | ||||
| @ -62,16 +62,16 @@ require ( | ||||
| 	github.com/go-openapi/validate v0.24.0 // indirect | ||||
| 	github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||||
| 	github.com/josharian/intern v1.0.0 // indirect | ||||
| 	github.com/klauspost/compress v1.17.9 // indirect | ||||
| 	github.com/klauspost/compress v1.17.11 // indirect | ||||
| 	github.com/mailru/easyjson v0.7.7 // indirect | ||||
| 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect | ||||
| 	github.com/oklog/ulid v1.3.1 // indirect | ||||
| 	github.com/opentracing/opentracing-go v1.2.0 // indirect | ||||
| 	github.com/pmezard/go-difflib v1.0.0 // indirect | ||||
| 	github.com/prometheus/client_model v0.6.1 // indirect | ||||
| 	github.com/prometheus/common v0.55.0 // indirect | ||||
| 	github.com/prometheus/common v0.62.0 // indirect | ||||
| 	github.com/prometheus/procfs v0.15.1 // indirect | ||||
| 	github.com/spf13/pflag v1.0.5 // indirect | ||||
| 	github.com/spf13/pflag v1.0.6 // indirect | ||||
| 	go.mongodb.org/mongo-driver v1.14.0 // indirect | ||||
| 	go.opentelemetry.io/otel v1.24.0 // indirect | ||||
| 	go.opentelemetry.io/otel/metric v1.24.0 // indirect | ||||
| @ -79,6 +79,6 @@ require ( | ||||
| 	golang.org/x/crypto v0.31.0 // indirect | ||||
| 	golang.org/x/sys v0.28.0 // indirect | ||||
| 	golang.org/x/text v0.21.0 // indirect | ||||
| 	google.golang.org/protobuf v1.34.2 // indirect | ||||
| 	google.golang.org/protobuf v1.36.1 // indirect | ||||
| 	gopkg.in/yaml.v3 v3.0.1 // indirect | ||||
| ) | ||||
|  | ||||
							
								
								
									
										30
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										30
									
								
								go.sum
									
									
									
									
									
								
							| @ -57,7 +57,7 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk | ||||
| github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= | ||||
| github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo= | ||||
| github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= | ||||
| github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= | ||||
| github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | ||||
| github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||||
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| @ -207,8 +207,8 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF | ||||
| github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= | ||||
| github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= | ||||
| github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= | ||||
| github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= | ||||
| github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= | ||||
| github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= | ||||
| github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= | ||||
| github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= | ||||
| github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= | ||||
| github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | ||||
| @ -239,13 +239,13 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= | ||||
| github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | ||||
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||
| github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= | ||||
| github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= | ||||
| github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA= | ||||
| github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= | ||||
| github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= | ||||
| github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= | ||||
| github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= | ||||
| github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= | ||||
| github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= | ||||
| github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= | ||||
| github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= | ||||
| github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= | ||||
| github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= | ||||
| github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= | ||||
| @ -259,10 +259,10 @@ github.com/sethvargo/go-envconfig v1.1.1/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/ | ||||
| github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= | ||||
| github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= | ||||
| github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= | ||||
| github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= | ||||
| github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= | ||||
| github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= | ||||
| github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | ||||
| github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= | ||||
| github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= | ||||
| github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= | ||||
| github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | ||||
| github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||||
| github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||||
| github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= | ||||
| @ -299,8 +299,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y | ||||
| go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= | ||||
| go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= | ||||
| go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= | ||||
| goauthentik.io/api/v3 v3.2024123.4 h1:JYLsUjkJ7kT+jHO72DyFTXFwKEGAcOOlLh36SRG9BDw= | ||||
| goauthentik.io/api/v3 v3.2024123.4/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= | ||||
| goauthentik.io/api/v3 v3.2024123.7 h1:vjmEnxXTHGFylJ9kTBFNYy4kcTrUM2hSIt3ja8gNVAY= | ||||
| goauthentik.io/api/v3 v3.2024123.7/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= | ||||
| golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||||
| golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||
| golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||
| @ -595,8 +595,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 | ||||
| google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= | ||||
| google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= | ||||
| google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= | ||||
| google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= | ||||
| google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= | ||||
| google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= | ||||
| google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= | ||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||
| gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | ||||
|  | ||||
| @ -95,7 +95,7 @@ func NewAPIController(akURL url.URL, token string) *APIController { | ||||
| 		time.Sleep(time.Second * 3) | ||||
| 	} | ||||
| 	if len(outposts.Results) < 1 { | ||||
| 		panic("No outposts found with given token, ensure the given token corresponds to an authentik Outpost") | ||||
| 		panic("No outposts found with given token, ensure the given token corresponds to an authenitk Outpost") | ||||
| 	} | ||||
| 	outpost := outposts.Results[0] | ||||
|  | ||||
|  | ||||
| @ -233,19 +233,15 @@ func (a *APIController) AddWSHandler(handler WSHandler) { | ||||
| 	a.wsHandlers = append(a.wsHandlers, handler) | ||||
| } | ||||
|  | ||||
| func (a *APIController) SendWS(inst WebsocketInstruction, args map[string]interface{}) error { | ||||
| 	msg := websocketMessage{ | ||||
| 		Instruction: inst, | ||||
| 		Args:        args, | ||||
| 	} | ||||
| 	err := a.wsConn.WriteJSON(msg) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func (a *APIController) SendWSHello(args map[string]interface{}) error { | ||||
| 	allArgs := a.getWebsocketPingArgs() | ||||
| 	for key, value := range args { | ||||
| 		allArgs[key] = value | ||||
| 	} | ||||
| 	return a.SendWS(WebsocketInstructionHello, args) | ||||
| 	aliveMsg := websocketMessage{ | ||||
| 		Instruction: WebsocketInstructionHello, | ||||
| 		Args:        allArgs, | ||||
| 	} | ||||
| 	err := a.wsConn.WriteJSON(aliveMsg) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| @ -1,19 +1,19 @@ | ||||
| package ak | ||||
|  | ||||
| type WebsocketInstruction int | ||||
| type websocketInstruction int | ||||
|  | ||||
| const ( | ||||
| 	// WebsocketInstructionAck Code used to acknowledge a previous message | ||||
| 	WebsocketInstructionAck WebsocketInstruction = 0 | ||||
| 	WebsocketInstructionAck websocketInstruction = 0 | ||||
| 	// WebsocketInstructionHello Code used to send a healthcheck keepalive | ||||
| 	WebsocketInstructionHello WebsocketInstruction = 1 | ||||
| 	WebsocketInstructionHello websocketInstruction = 1 | ||||
| 	// WebsocketInstructionTriggerUpdate Code received to trigger a config update | ||||
| 	WebsocketInstructionTriggerUpdate WebsocketInstruction = 2 | ||||
| 	WebsocketInstructionTriggerUpdate websocketInstruction = 2 | ||||
| 	// WebsocketInstructionProviderSpecific Code received to trigger some provider specific function | ||||
| 	WebsocketInstructionProviderSpecific WebsocketInstruction = 3 | ||||
| 	WebsocketInstructionProviderSpecific websocketInstruction = 3 | ||||
| ) | ||||
|  | ||||
| type websocketMessage struct { | ||||
| 	Instruction WebsocketInstruction   `json:"instruction"` | ||||
| 	Instruction websocketInstruction   `json:"instruction"` | ||||
| 	Args        map[string]interface{} `json:"args"` | ||||
| } | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	