diff --git a/.github/workflows/translation-compile.yml b/.github/workflows/translation-compile.yml new file mode 100644 index 000000000..2486a052b --- /dev/null +++ b/.github/workflows/translation-compile.yml @@ -0,0 +1,43 @@ +name: authentik-backend-translate-compile +on: + push: + branches: [ master ] + paths: + - '/locale/' + schedule: + - cron: "*/15 * * * *" + workflow_dispatch: + +env: + POSTGRES_DB: authentik + POSTGRES_USER: authentik + POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77" + +jobs: + compile: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: '3.9' + - id: cache-pipenv + uses: actions/cache@v2.1.6 + with: + path: ~/.local/share/virtualenvs + key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} + - name: prepare + env: + INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} + run: scripts/ci_prepare.sh + - name: run pylint + run: pipenv run ./manage.py compilemessages + - name: Create Pull Request + uses: peter-evans/create-pull-request@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: compile-backend-translation + commit-message: "core: compile backend translations" + title: "core: compile backend translations" + delete-branch: true + signoff: true diff --git a/Makefile b/Makefile index 68c776eb8..54cb3b84a 100644 --- a/Makefile +++ b/Makefile @@ -73,4 +73,4 @@ migrate: python -m lifecycle.migrate run: - WORKERS=1 go run -v cmd/server/main.go + go run -v cmd/server/main.go diff --git a/authentik/api/throttle.py b/authentik/api/throttle.py new file mode 100644 index 000000000..52418cebe --- /dev/null +++ b/authentik/api/throttle.py @@ -0,0 +1,18 @@ +"""Throttling classes""" +from typing import Type + +from django.views import View +from rest_framework.request import Request +from rest_framework.throttling import ScopedRateThrottle + + +class SessionThrottle(ScopedRateThrottle): + """Throttle based on session key""" + + def allow_request(self, request: Request, view): + if request._request.user.is_superuser: + return True + return super().allow_request(request, view) + + def get_cache_key(self, request: Request, view: Type[View]) -> str: + return f"authentik-throttle-session-{request._request.session.session_key}" diff --git a/authentik/api/v3/urls.py b/authentik/api/v3/urls.py index 3bb833a98..0df2981c0 100644 --- a/authentik/api/v3/urls.py +++ b/authentik/api/v3/urls.py @@ -68,6 +68,11 @@ from authentik.stages.authenticator_duo.api import ( DuoAdminDeviceViewSet, DuoDeviceViewSet, ) +from authentik.stages.authenticator_sms.api import ( + AuthenticatorSMSStageViewSet, + SMSAdminDeviceViewSet, + SMSDeviceViewSet, +) from authentik.stages.authenticator_static.api import ( AuthenticatorStaticStageViewSet, StaticAdminDeviceViewSet, @@ -165,6 +170,7 @@ router.register("propertymappings/scope", ScopeMappingViewSet) router.register("propertymappings/notification", NotificationWebhookMappingViewSet) router.register("authenticators/duo", DuoDeviceViewSet) +router.register("authenticators/sms", SMSDeviceViewSet) router.register("authenticators/static", StaticDeviceViewSet) router.register("authenticators/totp", TOTPDeviceViewSet) router.register("authenticators/webauthn", WebAuthnDeviceViewSet) @@ -173,6 +179,11 @@ router.register( DuoAdminDeviceViewSet, basename="admin-duodevice", ) +router.register( + "authenticators/admin/sms", + SMSAdminDeviceViewSet, + basename="admin-smsdevice", +) router.register( "authenticators/admin/static", StaticAdminDeviceViewSet, @@ -187,6 +198,7 @@ router.register( router.register("stages/all", StageViewSet) router.register("stages/authenticator/duo", AuthenticatorDuoStageViewSet) +router.register("stages/authenticator/sms", AuthenticatorSMSStageViewSet) router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet) router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet) router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet) diff --git a/authentik/flows/views/executor.py b/authentik/flows/views/executor.py index a1a4691ae..d08d31d42 100644 --- a/authentik/flows/views/executor.py +++ b/authentik/flows/views/executor.py @@ -17,10 +17,13 @@ from django.views.generic import View from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, PolymorphicProxySerializer, extend_schema from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView from sentry_sdk import capture_exception from structlog.stdlib import BoundLogger, get_logger +from authentik.api.throttle import SessionThrottle from authentik.core.models import USER_ATTRIBUTE_DEBUG from authentik.events.models import Event, EventAction, cleanse_dict from authentik.flows.challenge import ( @@ -97,10 +100,33 @@ class InvalidStageError(SentryIgnoredException): """Error raised when a challenge from a stage is not valid""" +class FlowPendingUserThrottle(ScopedRateThrottle): + """Custom throttle based on which user is pending""" + + def get_cache_key(self, request: Request, view) -> str: + if SESSION_KEY_PLAN not in request._request.session: + return "" + if PLAN_CONTEXT_PENDING_USER not in request._request.session[SESSION_KEY_PLAN].context: + return "" + user = request._request.session[SESSION_KEY_PLAN].context[PLAN_CONTEXT_PENDING_USER] + return f"authentik-throttle-flow-pending-{user.uid}" + + def allow_request(self, request: Request, view) -> bool: + if SESSION_KEY_PLAN not in request._request.session: + return True + if PLAN_CONTEXT_PENDING_USER not in request._request.session[SESSION_KEY_PLAN].context: + return True + if request._request.user.is_superuser: + return True + return super().allow_request(request, view) + + @method_decorator(xframe_options_sameorigin, name="dispatch") class FlowExecutorView(APIView): """Stage 1 Flow executor, passing requests to Stage Views""" + throttle_classes = [SessionThrottle, FlowPendingUserThrottle] + throttle_scope = "flow_executor" permission_classes = [AllowAny] flow: Flow diff --git a/authentik/policies/event_matcher/migrations/0019_alter_eventmatcherpolicy_app.py b/authentik/policies/event_matcher/migrations/0019_alter_eventmatcherpolicy_app.py new file mode 100644 index 000000000..b5403e033 --- /dev/null +++ b/authentik/policies/event_matcher/migrations/0019_alter_eventmatcherpolicy_app.py @@ -0,0 +1,79 @@ +# Generated by Django 3.2.8 on 2021-10-09 17:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies_event_matcher", "0018_alter_eventmatcherpolicy_action"), + ] + + operations = [ + migrations.AlterField( + model_name="eventmatcherpolicy", + name="app", + field=models.TextField( + blank=True, + choices=[ + ("authentik.admin", "authentik Admin"), + ("authentik.api", "authentik API"), + ("authentik.crypto", "authentik Crypto"), + ("authentik.events", "authentik Events"), + ("authentik.flows", "authentik Flows"), + ("authentik.lib", "authentik lib"), + ("authentik.outposts", "authentik Outpost"), + ("authentik.policies.dummy", "authentik Policies.Dummy"), + ("authentik.policies.event_matcher", "authentik Policies.Event Matcher"), + ("authentik.policies.expiry", "authentik Policies.Expiry"), + ("authentik.policies.expression", "authentik Policies.Expression"), + ("authentik.policies.hibp", "authentik Policies.HaveIBeenPwned"), + ("authentik.policies.password", "authentik Policies.Password"), + ("authentik.policies.reputation", "authentik Policies.Reputation"), + ("authentik.policies", "authentik Policies"), + ("authentik.providers.ldap", "authentik Providers.LDAP"), + ("authentik.providers.oauth2", "authentik Providers.OAuth2"), + ("authentik.providers.proxy", "authentik Providers.Proxy"), + ("authentik.providers.saml", "authentik Providers.SAML"), + ("authentik.recovery", "authentik Recovery"), + ("authentik.sources.ldap", "authentik Sources.LDAP"), + ("authentik.sources.oauth", "authentik Sources.OAuth"), + ("authentik.sources.plex", "authentik Sources.Plex"), + ("authentik.sources.saml", "authentik Sources.SAML"), + ("authentik.stages.authenticator_duo", "authentik Stages.Authenticator.Duo"), + ("authentik.stages.authenticator_sms", "authentik Stages.Authenticator.SMS"), + ( + "authentik.stages.authenticator_static", + "authentik Stages.Authenticator.Static", + ), + ("authentik.stages.authenticator_totp", "authentik Stages.Authenticator.TOTP"), + ( + "authentik.stages.authenticator_validate", + "authentik Stages.Authenticator.Validate", + ), + ( + "authentik.stages.authenticator_webauthn", + "authentik Stages.Authenticator.WebAuthn", + ), + ("authentik.stages.captcha", "authentik Stages.Captcha"), + ("authentik.stages.consent", "authentik Stages.Consent"), + ("authentik.stages.deny", "authentik Stages.Deny"), + ("authentik.stages.dummy", "authentik Stages.Dummy"), + ("authentik.stages.email", "authentik Stages.Email"), + ("authentik.stages.identification", "authentik Stages.Identification"), + ("authentik.stages.invitation", "authentik Stages.User Invitation"), + ("authentik.stages.password", "authentik Stages.Password"), + ("authentik.stages.prompt", "authentik Stages.Prompt"), + ("authentik.stages.user_delete", "authentik Stages.User Delete"), + ("authentik.stages.user_login", "authentik Stages.User Login"), + ("authentik.stages.user_logout", "authentik Stages.User Logout"), + ("authentik.stages.user_write", "authentik Stages.User Write"), + ("authentik.tenants", "authentik Tenants"), + ("authentik.core", "authentik Core"), + ("authentik.managed", "authentik Managed"), + ], + default="", + help_text="Match events created by selected application. When left empty, all applications are matched.", + ), + ), + ] diff --git a/authentik/root/settings.py b/authentik/root/settings.py index a1aa3dc4e..c2aa7aec1 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -93,12 +93,11 @@ INSTALLED_APPS = [ "django.contrib.humanize", "authentik.admin", "authentik.api", - "authentik.events", "authentik.crypto", + "authentik.events", "authentik.flows", - "authentik.outposts", "authentik.lib", - "authentik.policies", + "authentik.outposts", "authentik.policies.dummy", "authentik.policies.event_matcher", "authentik.policies.expiry", @@ -106,9 +105,10 @@ INSTALLED_APPS = [ "authentik.policies.hibp", "authentik.policies.password", "authentik.policies.reputation", - "authentik.providers.proxy", + "authentik.policies", "authentik.providers.ldap", "authentik.providers.oauth2", + "authentik.providers.proxy", "authentik.providers.saml", "authentik.recovery", "authentik.sources.ldap", @@ -116,6 +116,7 @@ INSTALLED_APPS = [ "authentik.sources.plex", "authentik.sources.saml", "authentik.stages.authenticator_duo", + "authentik.stages.authenticator_sms", "authentik.stages.authenticator_static", "authentik.stages.authenticator_totp", "authentik.stages.authenticator_validate", @@ -204,6 +205,9 @@ REST_FRAMEWORK = { ], "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "TEST_REQUEST_DEFAULT_FORMAT": "json", + "DEFAULT_THROTTLE_RATES": { + "flow_executor": "100/day", + }, } REDIS_PROTOCOL_PREFIX = "redis://" diff --git a/authentik/stages/authenticator_sms/__init__.py b/authentik/stages/authenticator_sms/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/stages/authenticator_sms/api.py b/authentik/stages/authenticator_sms/api.py new file mode 100644 index 000000000..712d10b18 --- /dev/null +++ b/authentik/stages/authenticator_sms/api.py @@ -0,0 +1,79 @@ +"""AuthenticatorSMSStage API Views""" +from django_filters.rest_framework.backends import DjangoFilterBackend +from rest_framework import mixins +from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.permissions import IsAdminUser +from rest_framework.serializers import ModelSerializer +from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet + +from authentik.api.authorization import OwnerFilter, OwnerPermissions +from authentik.core.api.used_by import UsedByMixin +from authentik.flows.api.stages import StageSerializer +from authentik.stages.authenticator_sms.models import AuthenticatorSMSStage, SMSDevice + + +class AuthenticatorSMSStageSerializer(StageSerializer): + """AuthenticatorSMSStage Serializer""" + + class Meta: + + model = AuthenticatorSMSStage + fields = StageSerializer.Meta.fields + [ + "configure_flow", + "provider", + "from_number", + "twilio_account_sid", + "twilio_auth", + ] + + +class AuthenticatorSMSStageViewSet(UsedByMixin, ModelViewSet): + """AuthenticatorSMSStage Viewset""" + + queryset = AuthenticatorSMSStage.objects.all() + serializer_class = AuthenticatorSMSStageSerializer + filterset_fields = "__all__" + ordering = ["name"] + + +class SMSDeviceSerializer(ModelSerializer): + """Serializer for sms authenticator devices""" + + class Meta: + + model = SMSDevice + fields = ["name", "pk", "phone_number"] + depth = 2 + extra_kwargs = { + "phone_number": {"read_only": True}, + } + + +class SMSDeviceViewSet( + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + UsedByMixin, + mixins.ListModelMixin, + GenericViewSet, +): + """Viewset for sms authenticator devices""" + + queryset = SMSDevice.objects.all() + serializer_class = SMSDeviceSerializer + permission_classes = [OwnerPermissions] + filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter] + search_fields = ["name"] + filterset_fields = ["name"] + ordering = ["name"] + + +class SMSAdminDeviceViewSet(ReadOnlyModelViewSet): + """Viewset for sms authenticator devices (for admins)""" + + permission_classes = [IsAdminUser] + queryset = SMSDevice.objects.all() + serializer_class = SMSDeviceSerializer + search_fields = ["name"] + filterset_fields = ["name"] + ordering = ["name"] diff --git a/authentik/stages/authenticator_sms/apps.py b/authentik/stages/authenticator_sms/apps.py new file mode 100644 index 000000000..97d9d6ae0 --- /dev/null +++ b/authentik/stages/authenticator_sms/apps.py @@ -0,0 +1,10 @@ +"""SMS""" +from django.apps import AppConfig + + +class AuthentikStageAuthenticatorSMSConfig(AppConfig): + """SMS App config""" + + name = "authentik.stages.authenticator_sms" + label = "authentik_stages_authenticator_sms" + verbose_name = "authentik Stages.Authenticator.SMS" diff --git a/authentik/stages/authenticator_sms/migrations/0001_initial.py b/authentik/stages/authenticator_sms/migrations/0001_initial.py new file mode 100644 index 000000000..dc5f3fab8 --- /dev/null +++ b/authentik/stages/authenticator_sms/migrations/0001_initial.py @@ -0,0 +1,100 @@ +# Generated by Django 3.2.8 on 2021-10-09 19:15 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_flows", "0024_alter_flow_compatibility_mode"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="AuthenticatorSMSStage", + 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", + ), + ), + ("provider", models.TextField(choices=[("twilio", "Twilio")])), + ("twilio_account_sid", models.TextField()), + ("twilio_auth", models.TextField()), + ( + "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": "SMS Authenticator Setup Stage", + "verbose_name_plural": "SMS Authenticator Setup Stages", + }, + bases=("authentik_flows.stage", models.Model), + ), + migrations.CreateModel( + name="SMSDevice", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "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.", + ), + ), + ("phone_number", models.TextField()), + ( + "stage", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_stages_authenticator_sms.authenticatorsmsstage", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "verbose_name": "SMS Device", + "verbose_name_plural": "SMS Devices", + }, + ), + ] diff --git a/authentik/stages/authenticator_sms/migrations/0002_authenticatorsmsstage_from_number.py b/authentik/stages/authenticator_sms/migrations/0002_authenticatorsmsstage_from_number.py new file mode 100644 index 000000000..9671cc70d --- /dev/null +++ b/authentik/stages/authenticator_sms/migrations/0002_authenticatorsmsstage_from_number.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2021-10-09 20:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_authenticator_sms", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="authenticatorsmsstage", + name="from_number", + field=models.TextField(default=""), + preserve_default=False, + ), + ] diff --git a/authentik/stages/authenticator_sms/migrations/__init__.py b/authentik/stages/authenticator_sms/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/stages/authenticator_sms/models.py b/authentik/stages/authenticator_sms/models.py new file mode 100644 index 000000000..2053c6b36 --- /dev/null +++ b/authentik/stages/authenticator_sms/models.py @@ -0,0 +1,118 @@ +"""OTP Time-based models""" +from typing import Optional, Type + +from django.contrib.auth import get_user_model +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.views import View +from django_otp.models import SideChannelDevice +from requests.exceptions import RequestException +from rest_framework.exceptions import ValidationError +from rest_framework.serializers import BaseSerializer +from structlog.stdlib import get_logger + +from authentik.core.types import UserSettingSerializer +from authentik.flows.models import ConfigurableStage, Stage +from authentik.lib.utils.http import get_http_session + +LOGGER = get_logger() + + +class SMSProviders(models.TextChoices): + """Supported SMS Providers""" + + TWILIO = "twilio" + + +class AuthenticatorSMSStage(ConfigurableStage, Stage): + """Use SMS-based TOTP instead of authenticator-based.""" + + provider = models.TextField(choices=SMSProviders.choices) + + from_number = models.TextField() + + twilio_account_sid = models.TextField() + twilio_auth = models.TextField() + + def send(self, token: str, device: "SMSDevice"): + """Send message via selected provider""" + if self.provider == SMSProviders.TWILIO: + return self.send_twilio(token, device) + raise ValueError(f"invalid provider {self.provider}") + + def send_twilio(self, token: str, device: "SMSDevice"): + """send sms via twilio provider""" + response = get_http_session().post( + f"https://api.twilio.com/2010-04-01/Accounts/{self.twilio_account_sid}/Messages.json", + data={ + "From": self.from_number, + "To": device.phone_number, + "Body": token, + }, + auth=(self.twilio_account_sid, self.twilio_auth), + ) + LOGGER.debug("Sent SMS", to=device.phone_number) + try: + response.raise_for_status() + except RequestException as exc: + LOGGER.warning("Error sending token by Twilio SMS", exc=exc, body=response.text) + if response.status_code == 400: + raise ValidationError(response.json().get("message")) + raise + + if "sid" not in response.json(): + message = response.json().get("message") + LOGGER.warning("Error sending token by Twilio SMS", message=message) + raise Exception(message) + + @property + def serializer(self) -> BaseSerializer: + from authentik.stages.authenticator_sms.api import AuthenticatorSMSStageSerializer + + return AuthenticatorSMSStageSerializer + + @property + def type(self) -> Type[View]: + from authentik.stages.authenticator_sms.stage import AuthenticatorSMSStageView + + return AuthenticatorSMSStageView + + @property + def component(self) -> str: + return "ak-stage-authenticator-sms-form" + + @property + def ui_user_settings(self) -> Optional[UserSettingSerializer]: + return UserSettingSerializer( + data={ + "title": str(self._meta.verbose_name), + "component": "ak-user-settings-authenticator-sms", + } + ) + + def __str__(self) -> str: + return f"SMS Authenticator Setup Stage {self.name}" + + class Meta: + + verbose_name = _("SMS Authenticator Setup Stage") + verbose_name_plural = _("SMS Authenticator Setup Stages") + + +class SMSDevice(SideChannelDevice): + """SMS Device""" + + user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + + # Connect to the stage to when validating access we know the API Credentials + stage = models.ForeignKey(AuthenticatorSMSStage, on_delete=models.CASCADE) + + phone_number = models.TextField() + + def __str__(self): + return self.name or str(self.user) + + class Meta: + + verbose_name = _("SMS Device") + verbose_name_plural = _("SMS Devices") diff --git a/authentik/stages/authenticator_sms/stage.py b/authentik/stages/authenticator_sms/stage.py new file mode 100644 index 000000000..409d9183e --- /dev/null +++ b/authentik/stages/authenticator_sms/stage.py @@ -0,0 +1,118 @@ +"""SMS Setup stage""" +from typing import Optional + +from django.http import HttpRequest, HttpResponse +from django.http.request import QueryDict +from django.utils.translation import gettext_lazy as _ +from rest_framework.exceptions import ValidationError +from rest_framework.fields import BooleanField, CharField, IntegerField +from structlog.stdlib import get_logger + +from authentik.flows.challenge import ( + Challenge, + ChallengeResponse, + ChallengeTypes, + WithUserInfoChallenge, +) +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.stage import ChallengeStageView +from authentik.stages.authenticator_sms.models import AuthenticatorSMSStage, SMSDevice +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT + +LOGGER = get_logger() +SESSION_SMS_DEVICE = "sms_device" + + +class AuthenticatorSMSChallenge(WithUserInfoChallenge): + """SMS Setup challenge""" + + # Set to true if no previous prompt stage set the phone number + # this stage will also check prompt_data.phone + phone_number_required = BooleanField(default=True) + component = CharField(default="ak-stage-authenticator-sms") + + +class AuthenticatorSMSChallengeResponse(ChallengeResponse): + """SMS Challenge response, device is set by get_response_instance""" + + device: SMSDevice + + code = IntegerField(required=False) + phone_number = CharField(required=False) + + component = CharField(default="ak-stage-authenticator-sms") + + def validate(self, attrs: dict) -> dict: + """Check""" + stage: AuthenticatorSMSStage = self.device.stage + if "code" not in attrs: + self.device.phone_number = attrs["phone_number"] + # No code yet, but we have a phone number, so send a verification message + stage.send(self.device.token, self.device) + 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 AuthenticatorSMSStageView(ChallengeStageView): + """OTP sms Setup stage""" + + response_class = AuthenticatorSMSChallengeResponse + + def _has_phone_number(self) -> Optional[str]: + context = self.executor.plan.context + if "phone" in context.get(PLAN_CONTEXT_PROMPT, {}): + LOGGER.debug("got phone number from plan context") + return context.get(PLAN_CONTEXT_PROMPT, {}).get("phone") + if SESSION_SMS_DEVICE in self.request.session: + LOGGER.debug("got phone number from device in session") + device: SMSDevice = self.request.session[SESSION_SMS_DEVICE] + if device.phone_number == "": + return None + return device.phone_number + return None + + def get_challenge(self, *args, **kwargs) -> Challenge: + return AuthenticatorSMSChallenge( + data={ + "type": ChallengeTypes.NATIVE.value, + "phone_number_required": self._has_phone_number() is None, + } + ) + + def get_response_instance(self, data: QueryDict) -> ChallengeResponse: + response = super().get_response_instance(data) + response.device = self.request.session[SESSION_SMS_DEVICE] + return response + + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) + if not user: + LOGGER.debug("No pending user, continuing") + return self.executor.stage_ok() + + # Currently, this stage only supports one device per user. If the user already + # has a device, just skip to the next stage + if SMSDevice.objects.filter(user=user).exists(): + return self.executor.stage_ok() + + stage: AuthenticatorSMSStage = self.executor.current_stage + + if SESSION_SMS_DEVICE not in self.request.session: + device = SMSDevice(user=user, confirmed=False, stage=stage) + device.generate_token(commit=False) + if phone_number := self._has_phone_number(): + device.phone_number = phone_number + self.request.session[SESSION_SMS_DEVICE] = device + return super().get(request, *args, **kwargs) + + def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: + """SMS Token is validated by challenge""" + device: SMSDevice = self.request.session[SESSION_SMS_DEVICE] + if not device.confirmed: + return self.challenge_invalid(response) + device.save() + del self.request.session[SESSION_SMS_DEVICE] + return self.executor.stage_ok() diff --git a/authentik/stages/authenticator_sms/tests.py b/authentik/stages/authenticator_sms/tests.py new file mode 100644 index 000000000..cb65c2256 --- /dev/null +++ b/authentik/stages/authenticator_sms/tests.py @@ -0,0 +1,95 @@ +"""Test SMS API""" +from unittest.mock import MagicMock, patch + +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import User +from authentik.flows.challenge import ChallengeTypes +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.stages.authenticator_sms.models import AuthenticatorSMSStage, SMSProviders +from authentik.stages.authenticator_sms.stage import SESSION_SMS_DEVICE + + +class AuthenticatorSMSStageTests(APITestCase): + """Test SMS API""" + + def setUp(self) -> None: + super().setUp() + self.flow = Flow.objects.create( + name="foo", + slug="foo", + designation=FlowDesignation.STAGE_CONFIGURATION, + ) + self.stage = AuthenticatorSMSStage.objects.create( + name="foo", + provider=SMSProviders.TWILIO, + configure_flow=self.flow, + ) + FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0) + self.user = User.objects.create(username="foo") + self.client.force_login(self.user) + + def test_stage_no_prefill(self): + """test stage""" + self.client.get( + reverse("authentik_flows:configure", kwargs={"stage_uuid": self.stage.stage_uuid}), + ) + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + ) + self.assertJSONEqual( + response.content, + { + "component": "ak-stage-authenticator-sms", + "flow_info": { + "background": self.flow.background_url, + "cancel_url": reverse("authentik_flows:cancel"), + "title": "", + }, + "pending_user": "foo", + "pending_user_avatar": ( + "https://secure.gravatar.com/avatar/d41d8cd98f00" + "b204e9800998ecf8427e?s=158&r=g" + ), + "phone_number_required": True, + "type": ChallengeTypes.NATIVE.value, + }, + ) + + def test_stage_submit(self): + """test stage (submit)""" + # Prepares session etc + self.test_stage_no_prefill() + sms_send_mock = MagicMock() + with patch( + "authentik.stages.authenticator_sms.models.AuthenticatorSMSStage.send", + sms_send_mock, + ): + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + data={"component": "ak-stage-authenticator-sms", "phone_number": "foo"}, + ) + self.assertEqual(response.status_code, 200) + sms_send_mock.assert_called_once() + + def test_stage_submit_full(self): + """test stage (submit)""" + # Prepares session etc + self.test_stage_submit() + sms_send_mock = MagicMock() + with patch( + "authentik.stages.authenticator_sms.models.AuthenticatorSMSStage.send", + sms_send_mock, + ): + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + data={ + "component": "ak-stage-authenticator-sms", + "phone_number": "foo", + "code": int(self.client.session[SESSION_SMS_DEVICE].token), + }, + ) + print(response.content) + self.assertEqual(response.status_code, 200) + sms_send_mock.assert_not_called() diff --git a/authentik/stages/authenticator_static/tests.py b/authentik/stages/authenticator_static/tests.py index 3c0cf0663..763cfd90c 100644 --- a/authentik/stages/authenticator_static/tests.py +++ b/authentik/stages/authenticator_static/tests.py @@ -6,7 +6,7 @@ from rest_framework.test import APITestCase from authentik.core.models import User -class AuthenticatorStaticStage(APITestCase): +class AuthenticatorStaticStageTests(APITestCase): """Test Static API""" def test_api_delete(self): diff --git a/authentik/stages/authenticator_totp/stage.py b/authentik/stages/authenticator_totp/stage.py index 083d5a180..75117beb9 100644 --- a/authentik/stages/authenticator_totp/stage.py +++ b/authentik/stages/authenticator_totp/stage.py @@ -42,7 +42,7 @@ class AuthenticatorTOTPChallengeResponse(ChallengeResponse): """Validate totp code""" if self.device is not None: if not self.device.verify_token(code): - raise ValidationError(_("OTP Code does not match")) + raise ValidationError(_("Code does not match")) return code diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index d35df56b1..470ad4c01 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -19,6 +19,7 @@ from authentik.core.api.utils import PassiveSerializer from authentik.core.models import User from authentik.lib.utils.http import get_client_ip from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice +from authentik.stages.authenticator_sms.models import SMSDevice from authentik.stages.authenticator_webauthn.models import WebAuthnDevice from authentik.stages.authenticator_webauthn.utils import generate_challenge, get_origin @@ -77,6 +78,18 @@ def get_webauthn_challenge(request: HttpRequest, device: WebAuthnDevice) -> dict return assertion +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) + + +def select_challenge_sms(request: HttpRequest, device: SMSDevice): + """Send SMS""" + device.generate_token() + device.stage.send(device.token, device) + + def validate_challenge_code(code: str, request: HttpRequest, user: User) -> str: """Validate code-based challenges. We test against every device, on purpose, as the user mustn't choose between totp and static devices.""" diff --git a/authentik/stages/authenticator_validate/models.py b/authentik/stages/authenticator_validate/models.py index 197b0eda3..e379c2249 100644 --- a/authentik/stages/authenticator_validate/models.py +++ b/authentik/stages/authenticator_validate/models.py @@ -18,6 +18,7 @@ class DeviceClasses(models.TextChoices): TOTP = "totp", _("TOTP") WEBAUTHN = "webauthn", _("WebAuthn") DUO = "duo", _("Duo") + SMS = "sms", _("SMS") def default_device_classes() -> list: diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index e559c0c63..0288fd6a4 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -9,9 +9,11 @@ from authentik.flows.challenge import ChallengeResponse, ChallengeTypes, WithUse from authentik.flows.models import NotConfiguredAction, Stage from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import ChallengeStageView +from authentik.stages.authenticator_sms.models import SMSDevice from authentik.stages.authenticator_validate.challenge import ( DeviceChallenge, get_challenge_for_device, + select_challenge, validate_challenge_code, validate_challenge_duo, validate_challenge_webauthn, @@ -31,6 +33,8 @@ class AuthenticatorValidationChallenge(WithUserInfoChallenge): class AuthenticatorValidationChallengeResponse(ChallengeResponse): """Challenge used for Code-based and WebAuthn authenticators""" + selected_challenge = DeviceChallenge(required=False) + code = CharField(required=False) webauthn = JSONField(required=False) duo = IntegerField(required=False) @@ -43,7 +47,7 @@ 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]) + self._challenge_allowed([DeviceClasses.TOTP, DeviceClasses.STATIC, DeviceClasses.SMS]) return validate_challenge_code(code, self.stage.request, self.stage.get_pending_user()) def validate_webauthn(self, webauthn: dict) -> dict: @@ -59,6 +63,22 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): self._challenge_allowed([DeviceClasses.DUO]) return validate_challenge_duo(duo, self.stage.request, self.stage.get_pending_user()) + def validate_selected_challenge(self, challenge: dict) -> dict: + """Check which challenge the user has selected. Actual logic only used for SMS stage.""" + # First check if the challenge is valid + for device_challenge in self.stage.request.session.get("device_challenges"): + if device_challenge.get("device_class", "") != challenge.get("device_class", ""): + raise ValidationError("invalid challenge selected") + if device_challenge.get("device_uid", "") != challenge.get("device_uid", ""): + 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("device does not exist") + select_challenge(self.stage.request, devices.first()) + return challenge + def validate(self, attrs: dict): # Checking if the given data is from a valid device class is done above # Here we only check if the any data was sent at all diff --git a/cmd/server/main.go b/cmd/server/main.go index 06e452dd4..453b4b11c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -20,6 +20,7 @@ var running = true func main() { log.SetLevel(log.DebugLevel) + log.SetFormatter(&log.JSONFormatter{}) config.DefaultConfig() err := config.LoadConfig("./authentik/lib/default.yml") if err != nil { @@ -80,7 +81,7 @@ func attemptStartBackend(g *gounicorn.GoUnicorn) { if !running { return } - log.WithField("logger", "authentik.g").WithError(err).Warning("gunicorn process died, restarting") + log.WithField("logger", "authentik.router").WithError(err).Warning("gunicorn process died, restarting") } } diff --git a/internal/gounicorn/gounicorn.go b/internal/gounicorn/gounicorn.go index 422d15421..3485ead23 100644 --- a/internal/gounicorn/gounicorn.go +++ b/internal/gounicorn/gounicorn.go @@ -19,7 +19,7 @@ type GoUnicorn struct { } func NewGoUnicorn() *GoUnicorn { - logger := log.WithField("logger", "authentik.g.unicorn") + logger := log.WithField("logger", "authentik.router.unicorn") g := &GoUnicorn{ log: logger, started: false, diff --git a/internal/web/middleware_sentry.go b/internal/web/middleware_sentry.go index 5adf33316..88329e66c 100644 --- a/internal/web/middleware_sentry.go +++ b/internal/web/middleware_sentry.go @@ -10,7 +10,7 @@ import ( func recoveryMiddleware() func(next http.Handler) http.Handler { sentryHandler := sentryhttp.New(sentryhttp.Options{}) - l := log.WithField("logger", "authentik.sentry") + l := log.WithField("logger", "authentik.router.sentry") return func(next http.Handler) http.Handler { sentryHandler.Handle(next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/web.go b/internal/web/web.go index 60ca4afed..c94580676 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -32,7 +32,7 @@ type WebServer struct { } func NewWebServer(g *gounicorn.GoUnicorn) *WebServer { - l := log.WithField("logger", "authentik.g.web") + l := log.WithField("logger", "authentik.router") mainHandler := mux.NewRouter() if config.G.ErrorReporting.Enabled { mainHandler.Use(recoveryMiddleware()) diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 26fd463e2..51efe20d4 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-10-09 18:07+0000\n" +"POT-Creation-Date: 2021-10-11 14:12+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -1214,6 +1214,27 @@ msgstr "" msgid "Duo Devices" msgstr "" +#: authentik/stages/authenticator_sms/models.py:97 +msgid "SMS Authenticator Setup Stage" +msgstr "" + +#: authentik/stages/authenticator_sms/models.py:98 +msgid "SMS Authenticator Setup Stages" +msgstr "" + +#: authentik/stages/authenticator_sms/models.py:116 +msgid "SMS Device" +msgstr "" + +#: authentik/stages/authenticator_sms/models.py:117 +msgid "SMS Devices" +msgstr "" + +#: authentik/stages/authenticator_sms/stage.py:54 +#: authentik/stages/authenticator_totp/stage.py:45 +msgid "Code does not match" +msgstr "" + #: authentik/stages/authenticator_static/models.py:48 msgid "Static Authenticator Stage" msgstr "" @@ -1238,10 +1259,6 @@ msgstr "" msgid "TOTP Authenticator Setup Stages" msgstr "" -#: authentik/stages/authenticator_totp/stage.py:45 -msgid "OTP Code does not match" -msgstr "" - #: authentik/stages/authenticator_validate/challenge.py:85 msgid "Invalid Token" msgstr "" @@ -1258,15 +1275,19 @@ msgstr "" msgid "Duo" msgstr "" -#: authentik/stages/authenticator_validate/models.py:56 +#: authentik/stages/authenticator_validate/models.py:21 +msgid "SMS" +msgstr "" + +#: authentik/stages/authenticator_validate/models.py:57 msgid "Device classes which can be used to authenticate" msgstr "" -#: authentik/stages/authenticator_validate/models.py:78 +#: authentik/stages/authenticator_validate/models.py:79 msgid "Authenticator Validation Stage" msgstr "" -#: authentik/stages/authenticator_validate/models.py:79 +#: authentik/stages/authenticator_validate/models.py:80 msgid "Authenticator Validation Stages" msgstr "" diff --git a/schema.yml b/schema.yml index 58fc8284c..df9d2ca1e 100644 --- a/schema.yml +++ b/schema.yml @@ -359,6 +359,80 @@ paths: $ref: '#/components/schemas/ValidationError' '403': $ref: '#/components/schemas/GenericError' + /authenticators/admin/sms/: + get: + operationId: authenticators_admin_sms_list + description: Viewset for sms authenticator devices (for admins) + parameters: + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + tags: + - authenticators + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedSMSDeviceList' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' + /authenticators/admin/sms/{id}/: + get: + operationId: authenticators_admin_sms_retrieve + description: Viewset for sms authenticator devices (for admins) + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this SMS Device. + required: true + tags: + - authenticators + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SMSDevice' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' /authenticators/admin/static/: get: operationId: authenticators_admin_static_list @@ -765,6 +839,190 @@ paths: $ref: '#/components/schemas/ValidationError' '403': $ref: '#/components/schemas/GenericError' + /authenticators/sms/: + get: + operationId: authenticators_sms_list + description: Viewset for sms authenticator devices + parameters: + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + tags: + - authenticators + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedSMSDeviceList' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' + /authenticators/sms/{id}/: + get: + operationId: authenticators_sms_retrieve + description: Viewset for sms authenticator devices + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this SMS Device. + required: true + tags: + - authenticators + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SMSDevice' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' + put: + operationId: authenticators_sms_update + description: Viewset for sms authenticator devices + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this SMS Device. + required: true + tags: + - authenticators + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SMSDeviceRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SMSDevice' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' + patch: + operationId: authenticators_sms_partial_update + description: Viewset for sms authenticator devices + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this SMS Device. + required: true + tags: + - authenticators + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedSMSDeviceRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SMSDevice' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' + delete: + operationId: authenticators_sms_destroy + description: Viewset for sms authenticator devices + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this SMS Device. + required: true + tags: + - authenticators + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' + /authenticators/sms/{id}/used_by/: + get: + operationId: authenticators_sms_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this SMS Device. + required: true + tags: + - authenticators + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' /authenticators/static/: get: operationId: authenticators_static_list @@ -7282,6 +7540,7 @@ paths: - authentik.sources.plex - authentik.sources.saml - authentik.stages.authenticator_duo + - authentik.stages.authenticator_sms - authentik.stages.authenticator_static - authentik.stages.authenticator_totp - authentik.stages.authenticator_validate @@ -13877,6 +14136,247 @@ paths: $ref: '#/components/schemas/ValidationError' '403': $ref: '#/components/schemas/GenericError' + /stages/authenticator/sms/: + get: + operationId: stages_authenticator_sms_list + description: AuthenticatorSMSStage Viewset + parameters: + - in: query + name: configure_flow + schema: + type: string + format: uuid + - in: query + name: from_number + schema: + type: string + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: provider + schema: + type: string + enum: + - twilio + - name: search + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: stage_uuid + schema: + type: string + format: uuid + - in: query + name: twilio_account_sid + schema: + type: string + - in: query + name: twilio_auth + schema: + type: string + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedAuthenticatorSMSStageList' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' + post: + operationId: stages_authenticator_sms_create + description: AuthenticatorSMSStage Viewset + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatorSMSStageRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatorSMSStage' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' + /stages/authenticator/sms/{stage_uuid}/: + get: + operationId: stages_authenticator_sms_retrieve + description: AuthenticatorSMSStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this SMS Authenticator Setup Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatorSMSStage' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' + put: + operationId: stages_authenticator_sms_update + description: AuthenticatorSMSStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this SMS Authenticator Setup Stage. + required: true + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatorSMSStageRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatorSMSStage' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' + patch: + operationId: stages_authenticator_sms_partial_update + description: AuthenticatorSMSStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this SMS Authenticator Setup Stage. + required: true + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedAuthenticatorSMSStageRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AuthenticatorSMSStage' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' + delete: + operationId: stages_authenticator_sms_destroy + description: AuthenticatorSMSStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this SMS Authenticator Setup Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' + /stages/authenticator/sms/{stage_uuid}/used_by/: + get: + operationId: stages_authenticator_sms_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this SMS Authenticator Setup Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + $ref: '#/components/schemas/ValidationError' + '403': + $ref: '#/components/schemas/GenericError' /stages/authenticator/static/: get: operationId: stages_authenticator_static_list @@ -18240,12 +18740,11 @@ components: enum: - authentik.admin - authentik.api - - authentik.events - authentik.crypto + - authentik.events - authentik.flows - - authentik.outposts - authentik.lib - - authentik.policies + - authentik.outposts - authentik.policies.dummy - authentik.policies.event_matcher - authentik.policies.expiry @@ -18253,9 +18752,10 @@ components: - authentik.policies.hibp - authentik.policies.password - authentik.policies.reputation - - authentik.providers.proxy + - authentik.policies - authentik.providers.ldap - authentik.providers.oauth2 + - authentik.providers.proxy - authentik.providers.saml - authentik.recovery - authentik.sources.ldap @@ -18263,6 +18763,7 @@ components: - authentik.sources.plex - authentik.sources.saml - authentik.stages.authenticator_duo + - authentik.stages.authenticator_sms - authentik.stages.authenticator_static - authentik.stages.authenticator_totp - authentik.stages.authenticator_validate @@ -18609,6 +19110,123 @@ components: - client_id - client_secret - name + AuthenticatorSMSChallenge: + type: object + description: SMS Setup challenge + properties: + type: + $ref: '#/components/schemas/ChallengeChoices' + flow_info: + $ref: '#/components/schemas/ContextualFlowInfo' + component: + type: string + default: ak-stage-authenticator-sms + response_errors: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/ErrorDetail' + pending_user: + type: string + pending_user_avatar: + type: string + phone_number_required: + type: boolean + default: true + required: + - pending_user + - pending_user_avatar + - type + AuthenticatorSMSChallengeResponseRequest: + type: object + description: SMS Challenge response, device is set by get_response_instance + properties: + component: + type: string + default: ak-stage-authenticator-sms + code: + type: integer + phone_number: + type: string + AuthenticatorSMSStage: + type: object + description: AuthenticatorSMSStage Serializer + properties: + pk: + type: string + format: uuid + readOnly: true + title: Stage uuid + name: + type: string + component: + type: string + readOnly: true + verbose_name: + type: string + readOnly: true + verbose_name_plural: + type: string + readOnly: true + flow_set: + type: array + items: + $ref: '#/components/schemas/Flow' + configure_flow: + type: string + format: uuid + nullable: true + description: Flow used by an authenticated user to configure this Stage. + If empty, user will not be able to configure this stage. + provider: + $ref: '#/components/schemas/ProviderEnum' + from_number: + type: string + twilio_account_sid: + type: string + twilio_auth: + type: string + required: + - component + - from_number + - name + - pk + - provider + - twilio_account_sid + - twilio_auth + - verbose_name + - verbose_name_plural + AuthenticatorSMSStageRequest: + type: object + description: AuthenticatorSMSStage Serializer + properties: + name: + type: string + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowRequest' + configure_flow: + type: string + format: uuid + nullable: true + description: Flow used by an authenticated user to configure this Stage. + If empty, user will not be able to configure this stage. + provider: + $ref: '#/components/schemas/ProviderEnum' + from_number: + type: string + twilio_account_sid: + type: string + twilio_auth: + type: string + required: + - from_number + - name + - provider + - twilio_account_sid + - twilio_auth AuthenticatorStaticChallenge: type: object description: Static authenticator challenge @@ -18920,6 +19538,8 @@ components: component: type: string default: ak-stage-authenticator-validate + selected_challenge: + $ref: '#/components/schemas/DeviceChallengeRequest' code: type: string webauthn: @@ -19232,6 +19852,7 @@ components: oneOf: - $ref: '#/components/schemas/AccessDeniedChallenge' - $ref: '#/components/schemas/AuthenticatorDuoChallenge' + - $ref: '#/components/schemas/AuthenticatorSMSChallenge' - $ref: '#/components/schemas/AuthenticatorStaticChallenge' - $ref: '#/components/schemas/AuthenticatorTOTPChallenge' - $ref: '#/components/schemas/AuthenticatorValidationChallenge' @@ -19252,6 +19873,7 @@ components: mapping: ak-stage-access-denied: '#/components/schemas/AccessDeniedChallenge' ak-stage-authenticator-duo: '#/components/schemas/AuthenticatorDuoChallenge' + ak-stage-authenticator-sms: '#/components/schemas/AuthenticatorSMSChallenge' ak-stage-authenticator-static: '#/components/schemas/AuthenticatorStaticChallenge' ak-stage-authenticator-totp: '#/components/schemas/AuthenticatorTOTPChallenge' ak-stage-authenticator-validate: '#/components/schemas/AuthenticatorValidationChallenge' @@ -19524,12 +20146,28 @@ components: - challenge - device_class - device_uid + DeviceChallengeRequest: + type: object + description: Single device challenge + properties: + device_class: + type: string + device_uid: + type: string + challenge: + type: object + additionalProperties: {} + required: + - challenge + - device_class + - device_uid DeviceClassesEnum: enum: - static - totp - webauthn - duo + - sms type: string DigestAlgorithmEnum: enum: @@ -20283,6 +20921,7 @@ components: FlowChallengeResponseRequest: oneOf: - $ref: '#/components/schemas/AuthenticatorDuoChallengeResponseRequest' + - $ref: '#/components/schemas/AuthenticatorSMSChallengeResponseRequest' - $ref: '#/components/schemas/AuthenticatorStaticChallengeResponseRequest' - $ref: '#/components/schemas/AuthenticatorTOTPChallengeResponseRequest' - $ref: '#/components/schemas/AuthenticatorValidationChallengeResponseRequest' @@ -20300,6 +20939,7 @@ components: propertyName: component mapping: ak-stage-authenticator-duo: '#/components/schemas/AuthenticatorDuoChallengeResponseRequest' + ak-stage-authenticator-sms: '#/components/schemas/AuthenticatorSMSChallengeResponseRequest' ak-stage-authenticator-static: '#/components/schemas/AuthenticatorStaticChallengeResponseRequest' ak-stage-authenticator-totp: '#/components/schemas/AuthenticatorTOTPChallengeResponseRequest' ak-stage-authenticator-validate: '#/components/schemas/AuthenticatorValidationChallengeResponseRequest' @@ -22411,6 +23051,41 @@ components: required: - pagination - results + PaginatedAuthenticatorSMSStageList: + type: object + properties: + pagination: + type: object + properties: + next: + type: number + previous: + type: number + count: + type: number + current: + type: number + total_pages: + type: number + start_index: + type: number + end_index: + type: number + required: + - next + - previous + - count + - current + - total_pages + - start_index + - end_index + results: + type: array + items: + $ref: '#/components/schemas/AuthenticatorSMSStage' + required: + - pagination + - results PaginatedAuthenticatorStaticStageList: type: object properties: @@ -24301,6 +24976,41 @@ components: required: - pagination - results + PaginatedSMSDeviceList: + type: object + properties: + pagination: + type: object + properties: + next: + type: number + previous: + type: number + count: + type: number + current: + type: number + total_pages: + type: number + start_index: + type: number + end_index: + type: number + required: + - next + - previous + - count + - current + - total_pages + - start_index + - end_index + results: + type: array + items: + $ref: '#/components/schemas/SMSDevice' + required: + - pagination + - results PaginatedScopeMappingList: type: object properties: @@ -25233,6 +25943,30 @@ components: writeOnly: true api_hostname: type: string + PatchedAuthenticatorSMSStageRequest: + type: object + description: AuthenticatorSMSStage Serializer + properties: + name: + type: string + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowRequest' + configure_flow: + type: string + format: uuid + nullable: true + description: Flow used by an authenticated user to configure this Stage. + If empty, user will not be able to configure this stage. + provider: + $ref: '#/components/schemas/ProviderEnum' + from_number: + type: string + twilio_account_sid: + type: string + twilio_auth: + type: string PatchedAuthenticatorStaticStageRequest: type: object description: AuthenticatorStaticStage Serializer @@ -26546,6 +27280,14 @@ components: description: 'Time offset when temporary users should be deleted. This only applies if your IDP uses the NameID Format ''transient'', and the user doesn''t log out manually. (Format: hours=1;minutes=2;seconds=3).' + PatchedSMSDeviceRequest: + type: object + description: Serializer for sms authenticator devices + properties: + name: + type: string + description: The human-readable name of this device. + maxLength: 64 PatchedScopeMappingRequest: type: object description: ScopeMapping Serializer @@ -27402,6 +28144,10 @@ components: - pk - verbose_name - verbose_name_plural + ProviderEnum: + enum: + - twilio + type: string ProviderRequest: type: object description: Provider Serializer @@ -28225,6 +28971,35 @@ components: - pre_authentication_flow - slug - sso_url + SMSDevice: + type: object + description: Serializer for sms authenticator devices + properties: + name: + type: string + description: The human-readable name of this device. + maxLength: 64 + pk: + type: integer + readOnly: true + title: ID + phone_number: + type: string + readOnly: true + required: + - name + - phone_number + - pk + SMSDeviceRequest: + type: object + description: Serializer for sms authenticator devices + properties: + name: + type: string + description: The human-readable name of this device. + maxLength: 64 + required: + - name ScopeMapping: type: object description: ScopeMapping Serializer diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts index b53e14165..ce88e3764 100644 --- a/web/src/elements/forms/Form.ts +++ b/web/src/elements/forms/Form.ts @@ -219,6 +219,9 @@ export class Form extends LitElement { element.errorMessage = errorMessage[camelToSnake(elementName)].join(", "); element.invalid = true; + } else { + element.errorMessage = ""; + element.invalid = false; } }); if ("non_field_errors" in errorMessage) { diff --git a/web/src/elements/table/Table.ts b/web/src/elements/table/Table.ts index 6ba782130..dcf3d74c1 100644 --- a/web/src/elements/table/Table.ts +++ b/web/src/elements/table/Table.ts @@ -15,6 +15,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { AKResponse } from "../../api/Client"; import { EVENT_REFRESH } from "../../constants"; +import { groupBy } from "../../utils"; import "../EmptyState"; import "../chips/Chip"; import "../chips/ChipGroup"; @@ -154,6 +155,12 @@ export abstract class Table extends LitElement { }); } + public groupBy(items: T[]): [string, T[]][] { + return groupBy(items, () => { + return ""; + }); + } + public fetch(): void { if (this.isLoading) { return; @@ -213,7 +220,22 @@ export abstract class Table extends LitElement { if (this.data.pagination.count === 0) { return [this.renderEmpty()]; } - return this.data.results.map((item: T) => { + const groupedResults = this.groupBy(this.data.results); + if (groupedResults.length === 1) { + return this.renderRowGroup(groupedResults[0][1]); + } + return groupedResults.map(([group, items]) => { + return html` + + ${group} + + + ${this.renderRowGroup(items)}`; + }); + } + + private renderRowGroup(items: T[]): TemplateResult[] { + return items.map((item) => { return html``; + case "ak-stage-authenticator-sms": + return html``; case "ak-flow-sources-plex": return html` { + static get styles(): CSSResult[] { + return [PFBase, PFAlert, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal]; + } + + renderPhoneNumber(): TemplateResult { + return html` + +
+ +
`; + } + + renderCode(): TemplateResult { + return html` + +
+ +
`; + } + + render(): TemplateResult { + if (!this.challenge) { + return html` `; + } + if (this.challenge.phoneNumberRequired) { + return this.renderPhoneNumber(); + } + return this.renderCode(); + } +} diff --git a/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts b/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts index 892a80ec9..6afa7c6a0 100644 --- a/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts +++ b/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts @@ -15,21 +15,17 @@ import { AuthenticatorValidationChallenge, AuthenticatorValidationChallengeResponseRequest, DeviceChallenge, + DeviceClassesEnum, + FlowsApi, } from "@goauthentik/api"; +import { DEFAULT_CONFIG } from "../../../api/Config"; import { BaseStage, StageHost } from "../base"; import { PasswordManagerPrefill } from "../identification/IdentificationStage"; import "./AuthenticatorValidateStageCode"; import "./AuthenticatorValidateStageDuo"; import "./AuthenticatorValidateStageWebAuthn"; -export enum DeviceClasses { - STATIC = "static", - TOTP = "totp", - WEBAUTHN = "webauthn", - DUO = "duo", -} - @customElement("ak-stage-authenticator-validate") export class AuthenticatorValidateStage extends BaseStage< @@ -38,8 +34,29 @@ export class AuthenticatorValidateStage > implements StageHost { + flowSlug = ""; + + _selectedDeviceChallenge?: DeviceChallenge; + @property({ attribute: false }) - selectedDeviceChallenge?: DeviceChallenge; + set selectedDeviceChallenge(value: DeviceChallenge | undefined) { + this._selectedDeviceChallenge = value; + // We don't use this.submit here, as we don't want to advance the flow. + // We just want to notify the backend which challenge has been selected. + new FlowsApi(DEFAULT_CONFIG).flowsExecutorSolve({ + flowSlug: this.host.flowSlug, + query: window.location.search.substring(1), + flowChallengeResponseRequest: { + // @ts-ignore + component: this.challenge.component || "", + selectedChallenge: value, + }, + }); + } + + get selectedDeviceChallenge(): DeviceChallenge | undefined { + return this._selectedDeviceChallenge; + } submit(payload: AuthenticatorValidationChallengeResponseRequest): Promise { return this.host?.submit(payload) || Promise.resolve(); @@ -74,7 +91,7 @@ export class AuthenticatorValidateStage renderDevicePickerSingle(deviceChallenge: DeviceChallenge): TemplateResult { switch (deviceChallenge.deviceClass) { - case DeviceClasses.DUO: + case DeviceClassesEnum.Duo: return html`

${t`Duo push-notifications`}

@@ -82,37 +99,30 @@ export class AuthenticatorValidateStage >${t`Receive a push notification on your phone to prove your identity.`}
`; - case DeviceClasses.WEBAUTHN: + case DeviceClassesEnum.Webauthn: return html`

${t`Authenticator`}

${t`Use a security key to prove your identity.`}
`; - case DeviceClasses.TOTP: - // TOTP is a bit special, assuming that TOTP is allowed from the backend, - // and we have a pre-filled value from the password manager, - // directly set the the TOTP device Challenge as active. - if (PasswordManagerPrefill.totp) { - console.debug( - "authentik/stages/authenticator_validate: found prefill totp code, selecting totp challenge", - ); - this.selectedDeviceChallenge = deviceChallenge; - // Delay the update as a re-render isn't triggered from here - setTimeout(() => { - this.requestUpdate(); - }, 100); - } + case DeviceClassesEnum.Totp: return html`

${t`Traditional authenticator`}

${t`Use a code-based authenticator.`}
`; - case DeviceClasses.STATIC: + case DeviceClassesEnum.Static: return html`

${t`Recovery keys`}

${t`In case you can't access any other method.`}
`; + case DeviceClassesEnum.Sms: + return html` +
+

${t`SMS`}

+ ${t`Tokens sent via SMS.`} +
`; default: break; } @@ -142,8 +152,9 @@ export class AuthenticatorValidateStage return html``; } switch (this.selectedDeviceChallenge?.deviceClass) { - case DeviceClasses.STATIC: - case DeviceClasses.TOTP: + case DeviceClassesEnum.Static: + case DeviceClassesEnum.Totp: + case DeviceClassesEnum.Sms: return html` 1} > `; - case DeviceClasses.WEBAUTHN: + case DeviceClassesEnum.Webauthn: return html` 1} > `; - case DeviceClasses.DUO: + case DeviceClassesEnum.Duo: return html` challenge.deviceClass === DeviceClassesEnum.Totp, + ); + if (PasswordManagerPrefill.totp && totpChallenge) { + console.debug( + "authentik/stages/authenticator_validate: found prefill totp code, selecting totp challenge", + ); + this.selectedDeviceChallenge = totpChallenge; + } return html`