diff --git a/.env.example b/.env.example index 84c72aa0fa..efeb4cc3f7 100644 --- a/.env.example +++ b/.env.example @@ -146,6 +146,7 @@ DATABASE_NAME=baserow # BASEROW_ENTERPRISE_AUDIT_LOG_CLEANUP_INTERVAL_MINUTES= # BASEROW_ENTERPRISE_AUDIT_LOG_RETENTION_DAYS= # BASEROW_ALLOW_MULTIPLE_SSO_PROVIDERS_FOR_SAME_ACCOUNT= +# BASEROW_OAUTH_BACKEND_URL= # BASEROW_PREMIUM_GROUPED_AGGREGATE_SERVICE_MAX_SERIES= # BASEROW_PREMIUM_GROUPED_AGGREGATE_SERVICE_MAX_AGG_BUCKETS= @@ -169,8 +170,10 @@ DATABASE_NAME=baserow # BASEROW_DISABLE_LOCKED_MIGRATIONS= +# BASEROW_TOTP_ISSUER_NAME= + # SENTRY_DSN= # SENTRY_BACKEND_DSN= # BASEROW_EMBEDDINGS_API_URL=http://embeddings -# BASEROW_ENTERPRISE_ASSISTANT_LLM_MODEL=groq/openai/gpt-oss-120b # Needs GROQ_API_KEY env var set too \ No newline at end of file +# BASEROW_ENTERPRISE_ASSISTANT_LLM_MODEL=groq/openai/gpt-oss-120b # Needs GROQ_API_KEY env var set too diff --git a/backend/requirements/base.in b/backend/requirements/base.in index 0bb5547e45..987610e50e 100644 --- a/backend/requirements/base.in +++ b/backend/requirements/base.in @@ -87,4 +87,6 @@ certifi==2025.4.26 # Pinned to address vulnerability. httpcore==1.0.9 # Pinned to address vulnerability. genson==1.3.0 dspy-ai==3.0.3 -litellm==1.77.7 # Pinned to avoid bug in 1.75.3 requiring litellm[proxy] \ No newline at end of file +litellm==1.77.7 # Pinned to avoid bug in 1.75.3 requiring litellm[proxy] +pyotp==2.9.0 +qrcode==8.2 diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt index d3b1a07d79..86d3af0f3f 100644 --- a/backend/requirements/base.txt +++ b/backend/requirements/base.txt @@ -585,6 +585,8 @@ pyopenssl==24.3.0 # ndg-httpsclient # pysaml2 # twisted +pyotp==2.9.0 + # via -r base.in pyparsing==3.2.1 # via jira2markdown pysaml2==7.5.0 @@ -623,6 +625,7 @@ pyyaml==6.0.2 # optuna # uvicorn redis==5.2.1 +qrcode==8.2 # via # -r base.in # celery-redbeat diff --git a/backend/src/baserow/api/two_factor_auth/__init__.py b/backend/src/baserow/api/two_factor_auth/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/baserow/api/two_factor_auth/errors.py b/backend/src/baserow/api/two_factor_auth/errors.py new file mode 100644 index 0000000000..6217e6e248 --- /dev/null +++ b/backend/src/baserow/api/two_factor_auth/errors.py @@ -0,0 +1,42 @@ +from rest_framework.status import ( + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, +) + +ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST = ( + "ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST", + HTTP_404_NOT_FOUND, + "The requested auth provider does not exist.", +) + +ERROR_TWO_FACTOR_AUTH_VERIFICATION_FAILED = ( + "ERROR_TWO_FACTOR_AUTH_VERIFICATION_FAILED", + HTTP_401_UNAUTHORIZED, + "Two-factor authentication verification failed.", +) + +ERROR_WRONG_PASSWORD = ( + "ERROR_WRONG_PASSWORD", + HTTP_403_FORBIDDEN, + "The provided password is incorrect.", +) + +ERROR_TWO_FACTOR_AUTH_ALREADY_CONFIGURED = ( + "ERROR_TWO_FACTOR_AUTH_ALREADY_CONFIGURED", + HTTP_400_BAD_REQUEST, + "Two-factor authentication already configured", +) + +ERROR_TWO_FACTOR_AUTH_NOT_CONFIGURED = ( + "ERROR_TWO_FACTOR_AUTH_NOT_CONFIGURED", + HTTP_400_BAD_REQUEST, + "Two-factor authentication not configured", +) + +ERROR_TWO_FACTOR_AUTH_CANNOT_BE_CONFIGURED = ( + "ERROR_TWO_FACTOR_AUTH_CANNOT_BE_CONFIGURED", + HTTP_400_BAD_REQUEST, + "Two-factor authentication cannot be configured", +) diff --git a/backend/src/baserow/api/two_factor_auth/serializers.py b/backend/src/baserow/api/two_factor_auth/serializers.py new file mode 100644 index 0000000000..004ed2d2aa --- /dev/null +++ b/backend/src/baserow/api/two_factor_auth/serializers.py @@ -0,0 +1,39 @@ +from django.utils.functional import lazy + +from rest_framework import serializers + +from baserow.core.two_factor_auth.models import TwoFactorAuthProviderModel +from baserow.core.two_factor_auth.registries import two_factor_auth_type_registry + + +class TwoFactorAuthSerializer(serializers.ModelSerializer): + type = serializers.SerializerMethodField(read_only=True) + + def get_type(self, instance): + return instance.get_type().type + + class Meta: + model = TwoFactorAuthProviderModel + fields = ["type"] + + +class CreateTwoFactorAuthSerializer(serializers.ModelSerializer): + type = serializers.ChoiceField( + choices=lazy(two_factor_auth_type_registry.get_types, list)(), + required=True, + help_text="The type of the two factor auth.", + ) + + class Meta: + model = TwoFactorAuthProviderModel + fields = ["type"] + + +class DisableTwoFactorAuthSerializer(serializers.Serializer): + password = serializers.CharField(required=True) + + +class VerifyTOTPSerializer(serializers.Serializer): + email = serializers.EmailField(required=True) + code = serializers.CharField(required=False) + backup_code = serializers.CharField(required=False) diff --git a/backend/src/baserow/api/two_factor_auth/tokens.py b/backend/src/baserow/api/two_factor_auth/tokens.py new file mode 100644 index 0000000000..5559cf66e4 --- /dev/null +++ b/backend/src/baserow/api/two_factor_auth/tokens.py @@ -0,0 +1,29 @@ +from django.conf import settings + +from rest_framework import permissions +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +from rest_framework_simplejwt.tokens import Token + + +class TwoFactorAccessToken(Token): + token_type = "2fa" # nosec + lifetime = settings.ACCESS_TOKEN_LIFETIME + + +class Require2faToken(permissions.BasePermission): + """ + Require that the provided JWT is two factor access token type. + """ + + def has_permission(self, request, view): + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return False + + token_string = auth_header.split(" ")[1] + + try: + token = TwoFactorAccessToken(token_string) + return token.token_type == "2fa" # nosec + except (InvalidToken, TokenError): + return False diff --git a/backend/src/baserow/api/two_factor_auth/urls.py b/backend/src/baserow/api/two_factor_auth/urls.py new file mode 100644 index 0000000000..5cc18da3a3 --- /dev/null +++ b/backend/src/baserow/api/two_factor_auth/urls.py @@ -0,0 +1,23 @@ +from django.urls import re_path + +from .views import ( + ConfigureTwoFactorAuthView, + DisableTwoFactorAuthView, + VerifyTOTPAuthView, +) + +app_name = "baserow.api.two_factor_auth" + +urlpatterns = [ + re_path( + r"^configuration/$", + ConfigureTwoFactorAuthView.as_view(), + name="configuration", + ), + re_path( + r"^disable/$", + DisableTwoFactorAuthView.as_view(), + name="disable", + ), + re_path(r"^verify/$", VerifyTOTPAuthView.as_view(), name="verify"), +] diff --git a/backend/src/baserow/api/two_factor_auth/views.py b/backend/src/baserow/api/two_factor_auth/views.py new file mode 100644 index 0000000000..a3af759e22 --- /dev/null +++ b/backend/src/baserow/api/two_factor_auth/views.py @@ -0,0 +1,215 @@ +from django.db import transaction + +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from baserow.api.decorators import ( + map_exceptions, + validate_body, + validate_body_custom_fields, +) +from baserow.api.schemas import get_error_schema +from baserow.api.two_factor_auth.errors import ( + ERROR_TWO_FACTOR_AUTH_ALREADY_CONFIGURED, + ERROR_TWO_FACTOR_AUTH_CANNOT_BE_CONFIGURED, + ERROR_TWO_FACTOR_AUTH_NOT_CONFIGURED, + ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST, + ERROR_TWO_FACTOR_AUTH_VERIFICATION_FAILED, + ERROR_WRONG_PASSWORD, +) +from baserow.api.two_factor_auth.serializers import ( + CreateTwoFactorAuthSerializer, + DisableTwoFactorAuthSerializer, + TwoFactorAuthSerializer, + VerifyTOTPSerializer, +) +from baserow.api.two_factor_auth.tokens import Require2faToken +from baserow.api.user.schemas import create_user_response_schema +from baserow.api.user.serializers import log_in_user +from baserow.api.utils import DiscriminatorCustomFieldsMappingSerializer +from baserow.core.models import User +from baserow.core.two_factor_auth.actions import ( + ConfigureTwoFactorAuthActionType, + DisableTwoFactorAuthActionType, +) +from baserow.core.two_factor_auth.exceptions import ( + TwoFactorAuthAlreadyConfigured, + TwoFactorAuthCannotBeConfigured, + TwoFactorAuthNotConfigured, + TwoFactorAuthTypeDoesNotExist, + VerificationFailed, + WrongPassword, +) +from baserow.core.two_factor_auth.handler import TwoFactorAuthHandler +from baserow.core.two_factor_auth.registries import ( + TOTPAuthProviderType, + two_factor_auth_type_registry, +) + + +class ConfigureTwoFactorAuthView(APIView): + permission_classes = (IsAuthenticated,) + + @extend_schema( + tags=["Auth"], + operation_id="configure_two_factor_auth", + description=( + "Configures two-factor authentication for the authenticated user." + ), + request=DiscriminatorCustomFieldsMappingSerializer( + two_factor_auth_type_registry, CreateTwoFactorAuthSerializer + ), + responses={ + 200: DiscriminatorCustomFieldsMappingSerializer( + two_factor_auth_type_registry, TwoFactorAuthSerializer + ), + 400: get_error_schema( + [ + "ERROR_TWO_FACTOR_AUTH_ALREADY_CONFIGURED", + "ERROR_REQUEST_BODY_VALIDATION", + ] + ), + 401: get_error_schema(["ERROR_TWO_FACTOR_AUTH_VERIFICATION_FAILED"]), + 404: get_error_schema(["ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST"]), + }, + ) + @map_exceptions( + { + TwoFactorAuthTypeDoesNotExist: ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST, + VerificationFailed: ERROR_TWO_FACTOR_AUTH_VERIFICATION_FAILED, + TwoFactorAuthAlreadyConfigured: ERROR_TWO_FACTOR_AUTH_ALREADY_CONFIGURED, + TwoFactorAuthCannotBeConfigured: ERROR_TWO_FACTOR_AUTH_CANNOT_BE_CONFIGURED, + } + ) + @validate_body_custom_fields( + two_factor_auth_type_registry, + base_serializer_class=CreateTwoFactorAuthSerializer, + ) + @transaction.atomic + def post(self, request, data: dict): + """ + Configures two-factor authentication for the authenticated user. + """ + + provider_type = data.pop("type") + provider = ConfigureTwoFactorAuthActionType.do( + request.user, provider_type, **data + ) + + serializer = two_factor_auth_type_registry.get_serializer( + provider, TwoFactorAuthSerializer + ) + return Response(serializer.data) + + @extend_schema( + tags=["Auth"], + operation_id="two_factor_auth_configuration", + description=( + "Returns two-factor auth configuration for the authenticated user." + ), + request=None, + responses={ + 200: DiscriminatorCustomFieldsMappingSerializer( + two_factor_auth_type_registry, TwoFactorAuthSerializer + ), + }, + ) + @transaction.atomic + def get(self, request): + """ + Returns two-factor configuration for the authenticated user. + """ + + provider = TwoFactorAuthHandler().get_provider(request.user) + if provider is None: + return Response( + {"allowed": request.user.password != ""}, # nosec + status=200, + ) + + serializer = two_factor_auth_type_registry.get_serializer( + provider, TwoFactorAuthSerializer + ) + return Response(serializer.data) + + +class DisableTwoFactorAuthView(APIView): + permission_classes = (IsAuthenticated,) + + @extend_schema( + tags=["Auth"], + operation_id="disable_two_factor_auth", + description=("Disables two-factor authentication for the authenticated user."), + request=DisableTwoFactorAuthSerializer, + responses={ + 204: None, + 403: get_error_schema(["ERROR_WRONG_PASSWORD"]), + 400: get_error_schema( + [ + "ERROR_REQUEST_BODY_VALIDATION", + "ERROR_TWO_FACTOR_AUTH_NOT_CONFIGURED", + ] + ), + }, + ) + @map_exceptions( + { + WrongPassword: ERROR_WRONG_PASSWORD, + TwoFactorAuthNotConfigured: ERROR_TWO_FACTOR_AUTH_NOT_CONFIGURED, + } + ) + @validate_body(DisableTwoFactorAuthSerializer, return_validated=True) + @transaction.atomic + def post(self, request, data: dict): + """ + Disables two-factor authentication for the authenticated user. + """ + + DisableTwoFactorAuthActionType.do(request.user, data.get("password")) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class VerifyTOTPAuthView(APIView): + permission_classes = (Require2faToken,) + + @extend_schema( + tags=["Auth"], + operation_id="verify_totp_auth", + description=("Verifies TOTP two-factor authentication"), + request=VerifyTOTPSerializer, + responses={ + 200: create_user_response_schema, + 400: get_error_schema( + [ + "ERROR_REQUEST_BODY_VALIDATION", + ] + ), + 401: get_error_schema(["ERROR_TWO_FACTOR_AUTH_VERIFICATION_FAILED"]), + 404: get_error_schema(["ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST"]), + }, + ) + @map_exceptions( + { + TwoFactorAuthTypeDoesNotExist: ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST, + VerificationFailed: ERROR_TWO_FACTOR_AUTH_VERIFICATION_FAILED, + } + ) + @validate_body(VerifyTOTPSerializer, return_validated=True) + @transaction.atomic + def post(self, request, data: dict): + """ + Verifies TOTP two-factor authentication. + """ + + TwoFactorAuthHandler().verify(TOTPAuthProviderType.type, **data) + + user = User.objects.filter(email=data["email"]).first() + return_data = log_in_user(request, user) + + return Response( + return_data, + status=status.HTTP_200_OK, + ) diff --git a/backend/src/baserow/api/urls.py b/backend/src/baserow/api/urls.py index 68b9d22f7e..3fe834b9b7 100755 --- a/backend/src/baserow/api/urls.py +++ b/backend/src/baserow/api/urls.py @@ -23,6 +23,7 @@ from .spectacular.views import CachedSpectacularJSONAPIView from .templates import urls as templates_urls from .trash import urls as trash_urls +from .two_factor_auth import urls as two_factor_urls from .user import urls as user_urls from .user_files import urls as user_files_urls from .user_sources import urls as user_source_urls @@ -41,6 +42,7 @@ ), path("settings/", include(settings_urls, namespace="settings")), path("auth-provider/", include(auth_provider_urls, namespace="auth_provider")), + path("two-factor-auth/", include(two_factor_urls, namespace="two_factor_auth")), path("user/", include(user_urls, namespace="user")), path("user-files/", include(user_files_urls, namespace="user_files")), path("workspaces/", include(workspace_urls, namespace="workspaces")), diff --git a/backend/src/baserow/api/user/serializers.py b/backend/src/baserow/api/user/serializers.py index e2974813e4..d40de18f90 100755 --- a/backend/src/baserow/api/user/serializers.py +++ b/backend/src/baserow/api/user/serializers.py @@ -1,3 +1,4 @@ +from datetime import timedelta from typing import Dict, Optional from django.conf import settings @@ -16,6 +17,7 @@ from rest_framework_simplejwt.tokens import RefreshToken from baserow.api.sessions import set_user_session_data_from_request +from baserow.api.two_factor_auth.tokens import TwoFactorAccessToken from baserow.api.user.jwt import get_user_from_token from baserow.api.user.registries import user_data_registry from baserow.api.user.validators import language_validation, password_validation @@ -30,6 +32,7 @@ from baserow.core.auth_provider.handler import PasswordProviderHandler from baserow.core.handler import CoreHandler from baserow.core.models import Settings, Template, UserProfile +from baserow.core.two_factor_auth.handler import TwoFactorAuthHandler from baserow.core.user.actions import SignInUserActionType from baserow.core.user.exceptions import DeactivatedUserException from baserow.core.user.handler import UserHandler @@ -333,6 +336,18 @@ def validate(self, attrs): attrs[self.username_field] = email super().validate(attrs) + + twofa_provider = TwoFactorAuthHandler().get_provider(self.user) + if twofa_provider: + provider_type = twofa_provider.get_type() + if provider_type.is_enabled(twofa_provider): + token = TwoFactorAccessToken.for_user(self.user) + token.set_exp(lifetime=timedelta(minutes=2)) + return { + "two_factor_auth": provider_type.type, + "token": str(token), + } + return log_in_user(self.context["request"], self.user) diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py index e3eefb0f61..70d7624ff2 100644 --- a/backend/src/baserow/config/settings/base.py +++ b/backend/src/baserow/config/settings/base.py @@ -1040,12 +1040,15 @@ def __setitem__(self, key, value): os.getenv("BASEROW_WEBHOOK_ROWS_ENTER_VIEW_BATCH_SIZE", BATCH_ROWS_SIZE_LIMIT) ) +OAUTH_BACKEND_URL = os.getenv("BASEROW_OAUTH_BACKEND_URL") or PUBLIC_BACKEND_URL INTEGRATIONS_ALLOW_PRIVATE_ADDRESS = bool( os.getenv("BASEROW_INTEGRATIONS_ALLOW_PRIVATE_ADDRESS", False) ) INTEGRATIONS_PERIODIC_TASK_CRONTAB = crontab(minute="*") +TOTP_ISSUER_NAME = os.getenv("BASEROW_TOTP_ISSUER_NAME", "Baserow") + # ======== WARNING ======== # Please read and understand everything at: # https://docs.djangoproject.com/en/3.2/ref/settings/#secure-proxy-ssl-header diff --git a/backend/src/baserow/core/apps.py b/backend/src/baserow/core/apps.py index 0feb919d51..3bbf16dc62 100755 --- a/backend/src/baserow/core/apps.py +++ b/backend/src/baserow/core/apps.py @@ -382,6 +382,13 @@ def ready(self): auth_provider_type_registry.register(PasswordAuthProviderType()) + from baserow.core.two_factor_auth.registries import ( + TOTPAuthProviderType, + two_factor_auth_type_registry, + ) + + two_factor_auth_type_registry.register(TOTPAuthProviderType()) + import baserow.core.notifications.receivers # noqa: F401 import baserow.core.notifications.tasks # noqa: F401 from baserow.core.notification_types import ( diff --git a/backend/src/baserow/core/migrations/0107_twofactorauthprovidermodel_totpauthprovidermodel_and_more.py b/backend/src/baserow/core/migrations/0107_twofactorauthprovidermodel_totpauthprovidermodel_and_more.py new file mode 100644 index 0000000000..12cdcbbcf3 --- /dev/null +++ b/backend/src/baserow/core/migrations/0107_twofactorauthprovidermodel_totpauthprovidermodel_and_more.py @@ -0,0 +1,115 @@ +# Generated by Django 5.0.14 on 2025-10-24 14:17 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import baserow.core.fields +import baserow.core.mixins + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("core", "0106_schemaoperation"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="TwoFactorAuthProviderModel", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", baserow.core.fields.SyncedDateTimeField(auto_now=True)), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="two_factor_auth_providers", + to="contenttypes.contenttype", + verbose_name="content type", + ), + ), + ( + "user", + models.ForeignKey( + help_text="User that setup 2fa with this provider", + on_delete=django.db.models.deletion.CASCADE, + related_name="two_factor_auth_providers", + to=settings.AUTH_USER_MODEL, + unique=True, + ), + ), + ], + options={ + "abstract": False, + }, + bases=( + baserow.core.mixins.PolymorphicContentTypeMixin, + baserow.core.mixins.WithRegistry, + models.Model, + ), + ), + migrations.CreateModel( + name="TOTPAuthProviderModel", + fields=[ + ( + "twofactorauthprovidermodel_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="core.twofactorauthprovidermodel", + ), + ), + ("enabled", models.BooleanField(default=False)), + ("secret", models.CharField(help_text="base32 secret", max_length=32)), + ("provisioning_url", models.CharField(max_length=255)), + ("provisioning_qr_code", models.TextField(blank=True)), + ], + options={ + "abstract": False, + }, + bases=("core.twofactorauthprovidermodel",), + ), + migrations.CreateModel( + name="TwoFactorAuthRecoveryCode", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "code", + models.CharField( + help_text="SHA-256 hash of the recovery code", max_length=64 + ), + ), + ( + "user", + models.ForeignKey( + help_text="User that setup 2fa with recovery codes", + on_delete=django.db.models.deletion.CASCADE, + related_name="two_factor_recovery_codes", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/backend/src/baserow/core/two_factor_auth/__init__.py b/backend/src/baserow/core/two_factor_auth/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/baserow/core/two_factor_auth/actions.py b/backend/src/baserow/core/two_factor_auth/actions.py new file mode 100644 index 0000000000..d41738a5c8 --- /dev/null +++ b/backend/src/baserow/core/two_factor_auth/actions.py @@ -0,0 +1,102 @@ +import dataclasses + +from django.contrib.auth.models import AbstractUser +from django.utils.translation import gettext_lazy as _ + +from baserow.core.action.registries import ( + ActionScopeStr, + ActionType, + ActionTypeDescription, +) +from baserow.core.action.scopes import RootActionScopeType +from baserow.core.two_factor_auth.handler import TwoFactorAuthHandler +from baserow.core.two_factor_auth.models import TwoFactorAuthProviderModel + + +class ConfigureTwoFactorAuthActionType(ActionType): + type = "configure_two_factor_auth" + description = ActionTypeDescription( + _("Configure two-factor authentication"), + _( + 'User "%(user_email)s" (%(user_id)s) configured %(provider_type)s (enabled %(is_enabled)s)' + " two-factor authentication." + ), + ) + analytics_params = [ + "user_id", + ] + + @dataclasses.dataclass + class Params: + user_id: int + user_email: str + provider_type: str + is_enabled: bool + + @classmethod + def do( + cls, user: AbstractUser, provider_type: str, **kwargs + ) -> TwoFactorAuthProviderModel: + """ + Configure two-factor auth for a user. + + :param user: The user the two-factor configuration is for. + :param provider_type: The provider type the configuration is for. + :param kwargs: Additional arguments for the provider. + :return: The updated provider. + """ + + provider = TwoFactorAuthHandler().configure_provider( + provider_type, user, **kwargs + ) + + cls.register_action( + user=user, + params=cls.Params( + user.id, user.email, provider_type, is_enabled=provider.is_enabled + ), + scope=cls.scope(), + ) + + return provider + + @classmethod + def scope(cls) -> ActionScopeStr: + return RootActionScopeType.value() + + +class DisableTwoFactorAuthActionType(ActionType): + type = "disable_two_factor_auth" + description = ActionTypeDescription( + _("Disable two-factor authentication"), + _('User "%(user_email)s" (%(user_id)s) disabled two-factor authentication.'), + ) + analytics_params = [ + "user_id", + ] + + @dataclasses.dataclass + class Params: + user_id: int + user_email: str + + @classmethod + def do(cls, user: AbstractUser, password: str) -> None: + """ + Disable two-factor auth for a user. + + :param user: The user the two-factor configuration is for. + :param password: The user's password for confirmation. + """ + + TwoFactorAuthHandler().disable(user, password) + + cls.register_action( + user=user, + params=cls.Params(user.id, user.email), + scope=cls.scope(), + ) + + @classmethod + def scope(cls) -> ActionScopeStr: + return RootActionScopeType.value() diff --git a/backend/src/baserow/core/two_factor_auth/exceptions.py b/backend/src/baserow/core/two_factor_auth/exceptions.py new file mode 100644 index 0000000000..976df929a3 --- /dev/null +++ b/backend/src/baserow/core/two_factor_auth/exceptions.py @@ -0,0 +1,22 @@ +class TwoFactorAuthTypeDoesNotExist(Exception): + ... + + +class VerificationFailed(Exception): + ... + + +class WrongPassword(Exception): + ... + + +class TwoFactorAuthAlreadyConfigured(Exception): + ... + + +class TwoFactorAuthNotConfigured(Exception): + ... + + +class TwoFactorAuthCannotBeConfigured(Exception): + ... diff --git a/backend/src/baserow/core/two_factor_auth/handler.py b/backend/src/baserow/core/two_factor_auth/handler.py new file mode 100644 index 0000000000..928a4eaefe --- /dev/null +++ b/backend/src/baserow/core/two_factor_auth/handler.py @@ -0,0 +1,130 @@ +from typing import cast + +from django.contrib.auth.models import AbstractUser +from django.db.models import QuerySet + +from baserow.core.two_factor_auth.exceptions import ( + TwoFactorAuthCannotBeConfigured, + TwoFactorAuthNotConfigured, + WrongPassword, +) +from baserow.core.two_factor_auth.registries import TwoFactorAuthProviderType + +from .models import TwoFactorAuthProviderModel +from .registries import two_factor_auth_type_registry +from .types import TwoFactorProviderForUpdate + + +class TwoFactorAuthHandler: + def get_provider( + self, user: AbstractUser, base_queryset: QuerySet | None = None + ) -> TwoFactorAuthProviderModel | None: + """ + Returns the user's provider from the database or None if no + provider is configured yet. + + :param user: The user the provider is for. + :param base_queryset: The base queryset to use to build the query. + :return: The provider instance. + """ + + queryset = ( + base_queryset if base_queryset else TwoFactorAuthProviderModel.objects.all() + ) + + provider = queryset.filter(user=user).first() + if provider is None: + return None + + provider_specific: TwoFactorAuthProviderModel = provider.specific + return provider_specific + + def get_provider_for_update( + self, + user: AbstractUser, + ) -> TwoFactorProviderForUpdate | None: + """ + Returns the user's provider from the database or None if no + provider is configured yet. + + :param user: The user the provider is for. + :return: The provider instance. + """ + + queryset = TwoFactorAuthProviderModel.objects.all().select_for_update( + of=("self",) + ) + provider = self.get_provider( + user, + base_queryset=queryset, + ) + if provider is None: + return None + + return cast( + TwoFactorProviderForUpdate, + provider, + ) + + def configure_provider( + self, + provider_type_str: str, + user: AbstractUser, + **kwargs, + ) -> TwoFactorAuthProviderModel: + """ + Configures the provider type for the user. + + :param provider_type_str: The two-factor auth type of the provider. + :param user: The user configuring the authentication. + :param kwargs: Additional attributes of the provider. + :return: The created two-factor auth provider model. + """ + + # Two-factor auth is only for password-based accounts. + # Accounts that don't have password set are accounts + # created via SSO. + if user.password == "": # nosec + raise TwoFactorAuthCannotBeConfigured + + provider_type: TwoFactorAuthProviderType = two_factor_auth_type_registry.get( + provider_type_str + ) + + provider = self.get_provider_for_update(user) + provider = provider_type.configure(user, provider, **kwargs) + provider.save() + return provider + + def disable(self, user: AbstractUser, password: str): + """ + Disables any configured provider for the user. + + :param user: The user for whom to disable the authentication. + :param password: Password for confirmation. + :raises WrongPassword: If the provided password doesn't match. + """ + + if not user.check_password(password): + raise WrongPassword + + provider = self.get_provider_for_update(user) + if provider: + provider_type = provider.get_type() + provider_type.disable(provider, user) + else: + raise TwoFactorAuthNotConfigured + + def verify(self, provider_type_str: str, **kwargs) -> bool: + """ + Verifies 2fa of the provider type. + + :param provider_type_str: The two-factor auth type of the provider. + :param kwargs: Additional verification attributes of the provider. + :return: If the verification request is accepted. + """ + + provider_type: TwoFactorAuthProviderType = two_factor_auth_type_registry.get( + provider_type_str + ) + return provider_type.verify(**kwargs) diff --git a/backend/src/baserow/core/two_factor_auth/models.py b/backend/src/baserow/core/two_factor_auth/models.py new file mode 100644 index 0000000000..1eb530217b --- /dev/null +++ b/backend/src/baserow/core/two_factor_auth/models.py @@ -0,0 +1,69 @@ +from django.contrib.contenttypes.models import ContentType +from django.db import models + +from baserow.core.mixins import ( + CreatedAndUpdatedOnMixin, + PolymorphicContentTypeMixin, + WithRegistry, +) + + +class TwoFactorAuthProviderModel( + CreatedAndUpdatedOnMixin, PolymorphicContentTypeMixin, WithRegistry, models.Model +): + """ + Base model for two factor auth. + """ + + content_type = models.ForeignKey( + ContentType, + verbose_name="content type", + related_name="two_factor_auth_providers", + on_delete=models.CASCADE, + ) + user = models.ForeignKey( + "auth.User", + unique=True, + on_delete=models.CASCADE, + related_name="two_factor_auth_providers", + help_text="User that setup 2fa with this provider", + ) + + @staticmethod + def get_type_registry(): + from baserow.core.two_factor_auth.registries import ( + two_factor_auth_type_registry, + ) + + return two_factor_auth_type_registry + + @property + def is_enabled(self): + return False + + +class TOTPAuthProviderModel(TwoFactorAuthProviderModel): + enabled = models.BooleanField(default=False) + secret = models.CharField(max_length=32, help_text="base32 secret") + provisioning_url = models.CharField(max_length=255) + provisioning_qr_code = models.TextField(blank=True) + + @property + def backup_codes(self): + return getattr(self, "_backup_codes", []) + + @property + def is_enabled(self): + return self.enabled + + +class TwoFactorAuthRecoveryCode(models.Model): + user = models.ForeignKey( + "auth.User", + on_delete=models.CASCADE, + related_name="two_factor_recovery_codes", + help_text="User that setup 2fa with recovery codes", + ) + code = models.CharField( + max_length=64, help_text="SHA-256 hash of the recovery code" + ) diff --git a/backend/src/baserow/core/two_factor_auth/registries.py b/backend/src/baserow/core/two_factor_auth/registries.py new file mode 100644 index 0000000000..60be1669f4 --- /dev/null +++ b/backend/src/baserow/core/two_factor_auth/registries.py @@ -0,0 +1,235 @@ +import hashlib +import secrets +import string +from abc import ABC, abstractmethod +from base64 import b64encode +from io import BytesIO + +from django.conf import settings +from django.contrib.auth.models import AbstractUser + +import pyotp +import qrcode +from rest_framework import serializers + +from baserow.core.registry import ( + CustomFieldsInstanceMixin, + CustomFieldsRegistryMixin, + Instance, + ModelInstanceMixin, + ModelRegistryMixin, + Registry, +) +from baserow.core.two_factor_auth.exceptions import ( + TwoFactorAuthAlreadyConfigured, + TwoFactorAuthTypeDoesNotExist, + VerificationFailed, +) +from baserow.core.two_factor_auth.models import ( + TOTPAuthProviderModel, + TwoFactorAuthProviderModel, + TwoFactorAuthRecoveryCode, +) + + +class TwoFactorAuthProviderType( + CustomFieldsInstanceMixin, + ModelInstanceMixin, + Instance, + ABC, +): + @abstractmethod + def configure( + self, user: AbstractUser, provider, **kwargs + ) -> TwoFactorAuthProviderModel: + """ + Method to configure or enable auth provider + for the user. + + :param user: The user that configures the 2fa. + :param provider: The provider instance to modify + if it exists. + """ + + raise NotImplementedError + + @abstractmethod + def is_enabled(self, provider) -> bool: + """ + Determines whether the given provider is + completely configured and in use. + + :param provider: The provider instance to check. + """ + + raise NotImplementedError + + @abstractmethod + def verify(self, **kwargs) -> bool: + """ + Determines whether the user should be logged + in based on the provider's parameters. + + Returns True if the authentication is successful + and raises VerificationFailed if not. + """ + + raise NotImplementedError + + @abstractmethod + def disable(self, provider, user): + """ + Disables existing 2fa provider for the user. + + :param provider: The enabled provider to disable. + :param user: The user associated with the + provider. + """ + + raise NotImplementedError + + +class TOTPAuthProviderType(TwoFactorAuthProviderType): + type = "totp" + model_class = TOTPAuthProviderModel + serializer_field_names = [ + "enabled", + "provisioning_url", + "provisioning_qr_code", + "backup_codes", + ] + serializer_field_overrides = { + "enabled": serializers.BooleanField(), + "provisioning_url": serializers.CharField(), + "provisioning_qr_code": serializers.CharField(), + "backup_codes": serializers.ListField(child=serializers.CharField()), + } + request_serializer_field_names = ["code"] + request_serializer_field_overrides = {"code": serializers.CharField(required=False)} + + def configure( + self, + user: AbstractUser, + provider: TOTPAuthProviderModel | None = None, + **kwargs, + ) -> TOTPAuthProviderModel: + if provider and provider.enabled: + raise TwoFactorAuthAlreadyConfigured + + if provider and kwargs.get("code"): + code = kwargs.get("code") + totp = pyotp.TOTP(provider.secret) + + if totp.verify(code): + provider.enabled = True + provider.provisioning_url = "" + provider.provisioning_qr_code = "" + + backup_codes_plaintext = self.generate_backup_codes() + self.store_backup_codes(provider, backup_codes_plaintext) + + provider._backup_codes = backup_codes_plaintext + return provider + else: + raise VerificationFailed + else: + if provider: + provider.delete() + + secret = pyotp.random_base32() + provisioning_url = pyotp.totp.TOTP(secret).provisioning_uri( + name=user.email, + issuer_name=settings.TOTP_ISSUER_NAME, + ) + + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(provisioning_url) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + buffered = BytesIO() + img.save(buffered) + qr_code_base64 = b64encode(buffered.getvalue()).decode("utf-8") + + return TOTPAuthProviderModel( + user=user, + enabled=False, + secret=secret, + provisioning_url=provisioning_url, + provisioning_qr_code=f"data:image/png;base64,{qr_code_base64}", + ) + + def store_backup_codes(self, provider, codes_plaintext): + recovery_codes = [ + TwoFactorAuthRecoveryCode( + user=provider.user, + code=hashlib.sha256(code.encode("utf-8")).hexdigest(), + ) + for code in codes_plaintext + ] + TwoFactorAuthRecoveryCode.objects.bulk_create(recovery_codes) + + def generate_backup_codes(self): + codes = [] + for _ in range(8): + alphabet = string.ascii_lowercase + string.digits + alphabet = ( + alphabet.replace("0", "") + .replace("o", "") + .replace("1", "") + .replace("i", "") + ) + code = "".join(secrets.choice(alphabet) for _ in range(10)) + formatted_code = f"{code[:5]}-{code[5:]}" + codes.append(formatted_code) + return codes + + def is_enabled(self, provider) -> bool: + return provider.enabled + + def verify(self, **kwargs) -> bool: + email = kwargs.get("email") + code = kwargs.get("code") + backup_code = kwargs.get("backup_code") + + if backup_code: + hashed = hashlib.sha256(backup_code.encode("utf-8")).hexdigest() + recovery_code = TwoFactorAuthRecoveryCode.objects.filter( + user__email=email, code=hashed + ).first() + if not recovery_code: + raise VerificationFailed + else: + recovery_code.delete() + return True + + provider = TwoFactorAuthProviderModel.objects.filter(user__email=email).first() + if not provider: + raise VerificationFailed + + totp = pyotp.TOTP(provider.specific.secret) + + if totp.verify(code): + return True + else: + raise VerificationFailed + + def disable(self, provider, user): + TwoFactorAuthRecoveryCode.objects.filter(user=user).delete() + provider.delete() + + +class TwoFactorAuthTypeRegistry( + CustomFieldsRegistryMixin, + ModelRegistryMixin[TwoFactorAuthProviderModel, TwoFactorAuthProviderType], + Registry[TwoFactorAuthProviderType], +): + """ + The registry that holds all the available 2fa types. + """ + + name = "two_factor_auth_type" + + does_not_exist_exception_class = TwoFactorAuthTypeDoesNotExist + + +two_factor_auth_type_registry: TwoFactorAuthTypeRegistry = TwoFactorAuthTypeRegistry() diff --git a/backend/src/baserow/core/two_factor_auth/types.py b/backend/src/baserow/core/two_factor_auth/types.py new file mode 100644 index 0000000000..c12c7c0c38 --- /dev/null +++ b/backend/src/baserow/core/two_factor_auth/types.py @@ -0,0 +1,7 @@ +from typing import NewType + +from baserow.core.two_factor_auth.models import TwoFactorAuthProviderModel + +TwoFactorProviderForUpdate = NewType( + "TwoFactorProviderForUpdate", TwoFactorAuthProviderModel +) diff --git a/backend/src/baserow/test_utils/fixtures/__init__.py b/backend/src/baserow/test_utils/fixtures/__init__.py index b011b98bbd..18d29a06a4 100755 --- a/backend/src/baserow/test_utils/fixtures/__init__.py +++ b/backend/src/baserow/test_utils/fixtures/__init__.py @@ -1,4 +1,5 @@ from baserow.core.db import get_collation_name +from baserow.test_utils.fixtures.two_factor_auth import TwoFactorAuthFixtures from .airtable import AirtableFixtures from .app_auth_provider import AppAuthProviderFixtures @@ -54,6 +55,7 @@ class Fixtures( ViewFixtures, FieldFixtures, TokenFixtures, + TwoFactorAuthFixtures, TemplateFixtures, RowFixture, TableWebhookFixture, diff --git a/backend/src/baserow/test_utils/fixtures/two_factor_auth.py b/backend/src/baserow/test_utils/fixtures/two_factor_auth.py new file mode 100644 index 0000000000..d3fd2c0e8b --- /dev/null +++ b/backend/src/baserow/test_utils/fixtures/two_factor_auth.py @@ -0,0 +1,26 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractUser + +import pyotp + +from baserow.core.two_factor_auth.models import TOTPAuthProviderModel +from baserow.core.two_factor_auth.registries import TOTPAuthProviderType + +User = get_user_model() + + +class TwoFactorAuthFixtures: + def configure_base_totp( + self, user: AbstractUser, **kwargs + ) -> TOTPAuthProviderModel: + provider = TOTPAuthProviderType().configure(user) + provider.save() + return provider + + def configure_totp(self, user: AbstractUser, **kwargs) -> TOTPAuthProviderModel: + provider = self.configure_base_totp(user) + totp = pyotp.TOTP(provider.secret) + valid_code = totp.now() + provider = TOTPAuthProviderType().configure(user, provider, code=valid_code) + provider.save() + return provider diff --git a/backend/src/baserow/test_utils/helpers.py b/backend/src/baserow/test_utils/helpers.py index 4234d6f6e5..19ff361ee7 100644 --- a/backend/src/baserow/test_utils/helpers.py +++ b/backend/src/baserow/test_utils/helpers.py @@ -644,6 +644,16 @@ def __eq__(self, other): return isinstance(other, dict) +class AnyList(dict): + """ + A class that can be used to check if a value is a list. Useful in tests when + you don't care about the contents. + """ + + def __eq__(self, other): + return isinstance(other, list) + + def load_test_cases(name: str) -> Union[List, Dict]: """ Load test data from the global cases directory. These cases are used to run the diff --git a/backend/tests/baserow/api/two_factor_auth/test_two_factor_views.py b/backend/tests/baserow/api/two_factor_auth/test_two_factor_views.py new file mode 100644 index 0000000000..b7b8515f8f --- /dev/null +++ b/backend/tests/baserow/api/two_factor_auth/test_two_factor_views.py @@ -0,0 +1,594 @@ +from urllib.parse import parse_qs, urlparse + +from django.urls import reverse + +import pyotp +import pytest +from rest_framework.status import ( + HTTP_200_OK, + HTTP_204_NO_CONTENT, + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, +) + +from baserow.core.two_factor_auth.models import TwoFactorAuthProviderModel +from baserow.test_utils.helpers import AnyList, AnyStr + + +@pytest.mark.django_db +def test_configuration_2fa_view_not_authenticated(api_client): + url = reverse("api:two_factor_auth:configuration") + response = api_client.get( + url, + ) + + response_json = response.json() + assert response.status_code == HTTP_401_UNAUTHORIZED, response_json + assert response_json["detail"] == "Authentication credentials were not provided." + + +@pytest.mark.django_db +def test_configuration_2fa_view_not_configured(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + + url = reverse("api:two_factor_auth:configuration") + response = api_client.get( + url, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_200_OK + assert response_json == {"allowed": True} + + +@pytest.mark.django_db +def test_configuration_2fa_view_totp_not_enabled(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + data_fixture.configure_base_totp(user) + + url = reverse("api:two_factor_auth:configuration") + response = api_client.get( + url, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_200_OK + assert response_json == { + "backup_codes": [], + "enabled": False, + "provisioning_qr_code": AnyStr(), + "provisioning_url": AnyStr(), + "type": "totp", + } + + +@pytest.mark.django_db +def test_configuration_2fa_view_totp_enabled(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + data_fixture.configure_totp(user) + + url = reverse("api:two_factor_auth:configuration") + response = api_client.get( + url, + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_200_OK + assert response_json == { + "backup_codes": [], + "enabled": True, + "provisioning_qr_code": "", + "provisioning_url": "", + "type": "totp", + } + + +@pytest.mark.django_db +def test_configuration_2fa_view_cannot_be_configured(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user.password = "" + user.save() + + url = reverse("api:two_factor_auth:configuration") + response = api_client.get( + url, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_200_OK, response_json + assert response_json == {"allowed": False} + + +@pytest.mark.django_db +def test_configure_2fa_view_not_authenticated(api_client): + url = reverse("api:two_factor_auth:configuration") + response = api_client.post( + url, + {"type": "totp"}, + format="json", + ) + + response_json = response.json() + assert response.status_code == HTTP_401_UNAUTHORIZED, response_json + assert response_json["detail"] == "Authentication credentials were not provided." + + +@pytest.mark.django_db +def test_configure_2fa_view_type_does_not_exist(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + + url = reverse("api:two_factor_auth:configuration") + response = api_client.post( + url, + {"type": "wrongtype"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_404_NOT_FOUND, response_json + assert response_json["error"] == "ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST" + + +@pytest.mark.django_db +def test_configure_2fa_view_type_not_provided(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + + url = reverse("api:two_factor_auth:configuration") + response = api_client.post( + url, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST, response_json + assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION" + + +@pytest.mark.django_db +def test_configure_2fa_view_cannot_be_configured(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + user.password = "" + user.save() + + url = reverse("api:two_factor_auth:configuration") + response = api_client.post( + url, + {"type": "totp"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST, response_json + assert response_json["error"] == "ERROR_TWO_FACTOR_AUTH_CANNOT_BE_CONFIGURED" + + +@pytest.mark.django_db +def test_configure_totp_2fa_view(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + + url = reverse("api:two_factor_auth:configuration") + response = api_client.post( + url, + {"type": "totp"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_200_OK, response_json + assert response_json == { + "backup_codes": [], + "enabled": False, + "provisioning_qr_code": AnyStr(), + "provisioning_url": AnyStr(), + "type": "totp", + } + + # generate correct TOTP code based on provisioning_url + parsed_url = urlparse(response_json["provisioning_url"]) + params = parse_qs(parsed_url.query) + secret = params.get("secret", [])[0] + totp = pyotp.TOTP(secret) + valid_code = totp.now() + + # provide TOTP code to confirm configuration + url = reverse("api:two_factor_auth:configuration") + response = api_client.post( + url, + {"type": "totp", "code": valid_code}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_200_OK, response_json + assert response_json == { + "backup_codes": AnyList(), + "enabled": True, + "provisioning_qr_code": "", + "provisioning_url": "", + "type": "totp", + } + + +@pytest.mark.django_db +def test_configure_totp_2fa_view_confirmation_failed_invalidcode( + api_client, data_fixture +): + user, token = data_fixture.create_user_and_token() + + url = reverse("api:two_factor_auth:configuration") + response = api_client.post( + url, + {"type": "totp"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_200_OK, response_json + assert response_json == { + "backup_codes": [], + "enabled": False, + "provisioning_qr_code": AnyStr(), + "provisioning_url": AnyStr(), + "type": "totp", + } + + # provide TOTP code to confirm configuration + url = reverse("api:two_factor_auth:configuration") + response = api_client.post( + url, + {"type": "totp", "code": "1234567"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_401_UNAUTHORIZED, response_json + assert response_json["error"] == "ERROR_TWO_FACTOR_AUTH_VERIFICATION_FAILED" + + +@pytest.mark.django_db +def test_configure_totp_2fa_view_failed_already_configured(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + data_fixture.configure_totp(user) + + url = reverse("api:two_factor_auth:configuration") + response = api_client.post( + url, + {"type": "totp"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST, response_json + assert response_json["error"] == "ERROR_TWO_FACTOR_AUTH_ALREADY_CONFIGURED" + + +@pytest.mark.django_db +def test_configure_totp_2fa_view_replaces_previous_configuration( + api_client, data_fixture +): + user, token = data_fixture.create_user_and_token() + + url = reverse("api:two_factor_auth:configuration") + response = api_client.post( + url, + {"type": "totp"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_200_OK, response_json + assert response_json == { + "backup_codes": [], + "enabled": False, + "provisioning_qr_code": AnyStr(), + "provisioning_url": AnyStr(), + "type": "totp", + } + + # when the totp is not fully enabled yet + # we want to replace the previous configuration + # as the user is trying to configure totp again + url = reverse("api:two_factor_auth:configuration") + response = api_client.post( + url, + {"type": "totp"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json2 = response.json() + assert response.status_code == HTTP_200_OK, response_json2 + assert response_json2 == { + "backup_codes": [], + "enabled": False, + "provisioning_qr_code": AnyStr(), + "provisioning_url": AnyStr(), + "type": "totp", + } + + assert response_json["provisioning_url"] != response_json2["provisioning_url"] + assert TwoFactorAuthProviderModel.objects.filter(user=user).count() == 1 + + +@pytest.mark.django_db +def test_disable_2fa_view_not_authenticated(api_client): + url = reverse("api:two_factor_auth:disable") + response = api_client.post( + url, + format="json", + ) + + response_json = response.json() + assert response.status_code == HTTP_401_UNAUTHORIZED, response_json + assert response_json["detail"] == "Authentication credentials were not provided." + + +@pytest.mark.django_db +def test_disable_2fa_view_wrong_password(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + + url = reverse("api:two_factor_auth:disable") + response = api_client.post( + url, + {"password": "123456"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_403_FORBIDDEN, response_json + assert response_json["error"] == "ERROR_WRONG_PASSWORD" + + +@pytest.mark.django_db +def test_disable_2fa_view_missing_password(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + + url = reverse("api:two_factor_auth:disable") + response = api_client.post( + url, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST, response_json + assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION" + + +@pytest.mark.django_db +def test_disable_2fa_view_not_configured(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + + url = reverse("api:two_factor_auth:disable") + response = api_client.post( + url, + {"password": "password"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST, response_json + assert response_json["error"] == "ERROR_TWO_FACTOR_AUTH_NOT_CONFIGURED" + + +@pytest.mark.django_db +def test_disable_2fa_view(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + data_fixture.configure_totp(user) + + url = reverse("api:two_factor_auth:disable") + response = api_client.post( + url, + {"password": "password"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + + assert response.status_code == HTTP_204_NO_CONTENT + + +@pytest.mark.django_db +def test_verify_totp_view_missing_email(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + data_fixture.configure_totp(user) + response = api_client.post( + reverse("api:user:token_auth"), + {"email": user.email, "password": "password"}, + format="json", + ) + response_json = response.json() + two_fa_token = response_json["token"] + + url = reverse("api:two_factor_auth:verify") + response = api_client.post( + url, + { + "code": "1234567", + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {two_fa_token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST, response_json + assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION" + + +@pytest.mark.django_db +def test_verify_totp_code_view(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + provider = data_fixture.configure_totp(user) + + response = api_client.post( + reverse("api:user:token_auth"), + {"email": user.email, "password": "password"}, + format="json", + ) + response_json = response.json() + two_fa_token = response_json["token"] + + totp = pyotp.TOTP(provider.secret) + valid_code = totp.now() + + url = reverse("api:two_factor_auth:verify") + response = api_client.post( + url, + { + "type": "totp", + "email": user.email, + "code": valid_code, + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {two_fa_token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_200_OK, response_json + assert response_json == { + "access_token": AnyStr(), + "active_licenses": {"instance_wide": {}, "per_workspace": {}}, + "permissions": AnyList(), + "refresh_token": AnyStr(), + "token": AnyStr(), + "user": { + "completed_guided_tours": [], + "completed_onboarding": False, + "email_notification_frequency": "instant", + "email_verified": False, + "first_name": user.first_name, + "id": user.id, + "is_staff": False, + "language": "en", + "username": user.email, + }, + "user_notifications": {"unread_count": 0}, + "user_session": AnyStr(), + } + + +@pytest.mark.django_db +def test_verify_totp_code_view_invalid(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + data_fixture.configure_totp(user) + + response = api_client.post( + reverse("api:user:token_auth"), + {"email": user.email, "password": "password"}, + format="json", + ) + response_json = response.json() + two_fa_token = response_json["token"] + + url = reverse("api:two_factor_auth:verify") + response = api_client.post( + url, + { + "type": "totp", + "email": user.email, + "code": "1234567", + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {two_fa_token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_401_UNAUTHORIZED, response_json + assert response_json["error"] == "ERROR_TWO_FACTOR_AUTH_VERIFICATION_FAILED" + + +@pytest.mark.django_db +def test_verify_totp_backup_code_view(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + provider = data_fixture.configure_totp(user) + backup_code = provider.backup_codes[0] + + response = api_client.post( + reverse("api:user:token_auth"), + {"email": user.email, "password": "password"}, + format="json", + ) + response_json = response.json() + two_fa_token = response_json["token"] + + url = reverse("api:two_factor_auth:verify") + response = api_client.post( + url, + { + "type": "totp", + "email": user.email, + "backup_code": backup_code, + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {two_fa_token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_200_OK, response_json + assert response_json == { + "access_token": AnyStr(), + "active_licenses": {"instance_wide": {}, "per_workspace": {}}, + "permissions": AnyList(), + "refresh_token": AnyStr(), + "token": AnyStr(), + "user": { + "completed_guided_tours": [], + "completed_onboarding": False, + "email_notification_frequency": "instant", + "email_verified": False, + "first_name": user.first_name, + "id": user.id, + "is_staff": False, + "language": "en", + "username": user.email, + }, + "user_notifications": {"unread_count": 0}, + "user_session": AnyStr(), + } + + +@pytest.mark.django_db +def test_verify_totp_backup_code_view_invalid(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + data_fixture.configure_totp(user) + + response = api_client.post( + reverse("api:user:token_auth"), + {"email": user.email, "password": "password"}, + format="json", + ) + response_json = response.json() + two_fa_token = response_json["token"] + + url = reverse("api:two_factor_auth:verify") + response = api_client.post( + url, + { + "type": "totp", + "email": user.email, + "backup_code": "XXXXX-XXXXX", + }, + format="json", + HTTP_AUTHORIZATION=f"Bearer {two_fa_token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_401_UNAUTHORIZED, response_json + assert response_json["error"] == "ERROR_TWO_FACTOR_AUTH_VERIFICATION_FAILED" diff --git a/backend/tests/baserow/api/users/test_token_auth.py b/backend/tests/baserow/api/users/test_token_auth.py index 41e8648dff..b171479056 100644 --- a/backend/tests/baserow/api/users/test_token_auth.py +++ b/backend/tests/baserow/api/users/test_token_auth.py @@ -771,3 +771,33 @@ def test_token_blacklist(api_client, data_fixture): response_json = response.json() assert response.status_code == HTTP_401_UNAUTHORIZED assert response_json["error"] == "ERROR_INVALID_REFRESH_TOKEN" + + +@pytest.mark.django_db +def test_token_auth_two_factor_token_is_not_access_token(api_client, data_fixture): + data_fixture.create_password_provider() + user = data_fixture.create_user(email="test@example.com", password="password") + data_fixture.configure_totp(user) + + response = api_client.post( + reverse("api:user:token_auth"), + {"email": "test@example.com", "password": "password"}, + format="json", + ) + + response_json = response.json() + two_fa_token = response_json["token"] + + # using 2fa token as access token should not work + response = api_client.patch( + reverse("api:user:account"), + { + "first_name": "Changed name", + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {two_fa_token}", + ) + + response_json = response.json() + assert response.status_code == HTTP_401_UNAUTHORIZED, response_json + assert response_json["error"] == "ERROR_INVALID_ACCESS_TOKEN" diff --git a/changelog/entries/unreleased/feature/725_add_totp_2fa_support.json b/changelog/entries/unreleased/feature/725_add_totp_2fa_support.json new file mode 100644 index 0000000000..dcc0fc04fd --- /dev/null +++ b/changelog/entries/unreleased/feature/725_add_totp_2fa_support.json @@ -0,0 +1,8 @@ +{ + "type": "feature", + "message": "Add TOTP 2fa support", + "domain": "core", + "issue_number": 725, + "bullet_points": [], + "created_at": "2025-10-27" +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 4da8461674..4c59b0b99b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -233,7 +233,8 @@ x-backend-variables: BASEROW_PREMIUM_GROUPED_AGGREGATE_SERVICE_MAX_AGG_BUCKETS: BASEROW_ENTERPRISE_ASSISTANT_LLM_MODEL: BASEROW_EMBEDDINGS_API_URL: - + BASEROW_OAUTH_BACKEND_URL: + BASEROW_TOTP_ISSUER_NAME: services: # A caddy reverse proxy sitting in-front of all the services. Responsible for routing diff --git a/enterprise/backend/src/baserow_enterprise/sso/oauth2/auth_provider_types.py b/enterprise/backend/src/baserow_enterprise/sso/oauth2/auth_provider_types.py index 551c69e5ce..90fd29e402 100644 --- a/enterprise/backend/src/baserow_enterprise/sso/oauth2/auth_provider_types.py +++ b/enterprise/backend/src/baserow_enterprise/sso/oauth2/auth_provider_types.py @@ -32,7 +32,7 @@ OpenIdConnectAuthProviderModel, ) -OAUTH_BACKEND_URL = settings.PUBLIC_BACKEND_URL +OAUTH_BACKEND_URL = settings.OAUTH_BACKEND_URL _is_url_already_loaded = False diff --git a/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_enterprise_data_sync_handler.py b/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_enterprise_data_sync_handler.py index 5be0502017..4ed0c878b6 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_enterprise_data_sync_handler.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_enterprise_data_sync_handler.py @@ -49,7 +49,9 @@ def test_update_periodic_data_sync_interval_licence_check(enterprise_data_fixtur @pytest.mark.django_db @pytest.mark.data_sync @override_settings(DEBUG=True) -def test_update_periodic_data_sync_interval_check_permissions(enterprise_data_fixture): +def test_update_periodic_data_sync_interval_check_permissions( + enterprise_data_fixture, synced_roles +): enterprise_data_fixture.enable_enterprise() user = enterprise_data_fixture.create_user() @@ -361,7 +363,9 @@ def test_call_hourly_periodic_data_sync_syncs(enterprise_data_fixture): @override_settings(DEBUG=True) @patch("baserow_enterprise.data_sync.handler.sync_periodic_data_sync") def test_call_periodic_data_sync_syncs_starts_task( - mock_sync_periodic_data_sync, enterprise_data_fixture + mock_sync_periodic_data_sync, + enterprise_data_fixture, + synced_roles, ): enterprise_data_fixture.enable_enterprise() user = enterprise_data_fixture.create_user() @@ -411,7 +415,7 @@ def test_skip_automatically_deactivated_periodic_data_syncs(enterprise_data_fixt @pytest.mark.django_db(transaction=True, databases=["default", "default-copy"]) @pytest.mark.data_sync @override_settings(DEBUG=True) -def test_skip_locked_data_syncs(enterprise_data_fixture): +def test_skip_locked_data_syncs(enterprise_data_fixture, synced_roles): enterprise_data_fixture.enable_enterprise() user = enterprise_data_fixture.create_user() diff --git a/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_postgresql_data_sync.py b/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_postgresql_data_sync.py index d26cd8def3..1eaa69bff0 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_postgresql_data_sync.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_postgresql_data_sync.py @@ -21,7 +21,7 @@ @pytest.mark.django_db(transaction=True) @override_settings(DEBUG=True) def test_create_row_in_postgresql_table( - enterprise_data_fixture, create_postgresql_test_table + enterprise_data_fixture, create_postgresql_test_table, synced_roles ): enterprise_data_fixture.enable_enterprise() default_database = settings.DATABASES["default"] @@ -120,6 +120,7 @@ def test_create_row_in_postgresql_table( def test_update_row_in_postgresql_table( enterprise_data_fixture, create_postgresql_test_table, + synced_roles, ): enterprise_data_fixture.enable_enterprise() default_database = settings.DATABASES["default"] @@ -235,6 +236,7 @@ def test_update_row_in_postgresql_table( def test_update_row_in_postgresql_table_with_multiple_primary_keys( enterprise_data_fixture, create_postgresql_test_table, + synced_roles, ): enterprise_data_fixture.enable_enterprise() default_database = settings.DATABASES["default"] @@ -344,6 +346,7 @@ def test_update_row_in_postgresql_table_with_multiple_primary_keys( def test_skip_update_row_in_postgresql_table_if_unique_primary_is_empty( enterprise_data_fixture, create_postgresql_test_table, + synced_roles, ): enterprise_data_fixture.enable_enterprise() default_database = settings.DATABASES["default"] @@ -431,6 +434,7 @@ def test_skip_update_row_in_postgresql_table_if_unique_primary_is_empty( def test_delete_row_in_postgresql_table( enterprise_data_fixture, create_postgresql_test_table, + synced_roles, ): enterprise_data_fixture.enable_enterprise() default_database = settings.DATABASES["default"] @@ -505,6 +509,7 @@ def test_delete_row_in_postgresql_table( def test_skip_delete_row_in_postgresql_table_if_unique_primary_is_empty( enterprise_data_fixture, create_postgresql_test_table, + synced_roles, ): enterprise_data_fixture.enable_enterprise() default_database = settings.DATABASES["default"] diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json index 96c266906a..1627e23a17 100644 --- a/web-frontend/locales/en.json +++ b/web-frontend/locales/en.json @@ -94,7 +94,8 @@ "emailNotifications": "Email notifications", "tokens": "Database tokens", "deleteAccount": "Delete account", - "mcpEndpoint": "MCP server" + "mcpEndpoint": "MCP server", + "twoFactorAuth": "Two-factor auth" }, "userFileUploadType": { "file": "my device", diff --git a/web-frontend/modules/core/assets/icons/password-check.svg b/web-frontend/modules/core/assets/icons/password-check.svg new file mode 100644 index 0000000000..0370112eab --- /dev/null +++ b/web-frontend/modules/core/assets/icons/password-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web-frontend/modules/core/assets/scss/components/all.scss b/web-frontend/modules/core/assets/scss/components/all.scss index 693b6a8e1a..e0e247187d 100644 --- a/web-frontend/modules/core/assets/scss/components/all.scss +++ b/web-frontend/modules/core/assets/scss/components/all.scss @@ -141,6 +141,12 @@ @import 'group_invite_form'; @import 'settings/members/edit_role_context'; @import 'settings/members/member_role_field'; +@import 'settings/two_factor_auth/two_factor_auth_empty'; +@import 'settings/two_factor_auth/enable_with_qr_code'; +@import 'settings/two_factor_auth/auth_code_input'; +@import 'settings/two_factor_auth/save_backup_code'; +@import 'settings/two_factor_auth/two_factor_enabled'; +@import 'settings/two_factor_auth/disable_two_factor'; @import 'badge'; @import 'badge_counter'; @import 'badge_collaborator'; @@ -174,6 +180,7 @@ @import 'url_input'; @import 'rich_text_editor'; @import 'radio'; +@import 'radio_card'; @import 'read_only_form'; @import 'onboarding'; @import 'template_card'; diff --git a/web-frontend/modules/core/assets/scss/components/form.scss b/web-frontend/modules/core/assets/scss/components/form.scss index e12ce36b07..51c7d3e0bc 100644 --- a/web-frontend/modules/core/assets/scss/components/form.scss +++ b/web-frontend/modules/core/assets/scss/components/form.scss @@ -146,6 +146,10 @@ &.actions--right { justify-content: right; } + + &.actions--gap { + gap: 12px; + } } .action__links { diff --git a/web-frontend/modules/core/assets/scss/components/radio_card.scss b/web-frontend/modules/core/assets/scss/components/radio_card.scss new file mode 100644 index 0000000000..8142546994 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/radio_card.scss @@ -0,0 +1,44 @@ +.radio-card { + display: flex; + flex-direction: row; + gap: 8px; + padding: 14px 16px 16px; + border-radius: 6px; + border: 1.5px solid $palette-neutral-400; + + @include elevation($elevation-low); +} + +.radio-card--selected { + border: 1.5px solid $palette-blue-500; +} + +.radio-card__input { + padding-top: 5px; +} + +.radio-card__content { + display: flex; + flex-direction: column; + gap: 8px; +} + +.radio-card__labels { + display: flex; + flex-direction: row; + gap: 8px; + padding-top: 2px; +} + +.radio-card__label { + padding-top: 4px; + font-size: 13px; + color: $palette-neutral-1200; + font-weight: 500; +} + +.radio-card__description { + color: $palette-neutral-900; + line-height: 20px; + font-size: 12px; +} diff --git a/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/auth_code_input.scss b/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/auth_code_input.scss new file mode 100644 index 0000000000..8b63b70c04 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/auth_code_input.scss @@ -0,0 +1,24 @@ +.auth-code-input { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; + width: 100%; + max-width: 324px; + + &.auth-code-input--full-width { + max-width: 100%; + } +} + +.auth-code-input__input { + height: 52px; + font-size: 18px; + color: $palette-neutral-900; + border-radius: 6px; + border: 1px solid $palette-neutral-400; + text-align: center; +} + +.auth-code-input__input--filled { + background: $palette-neutral-100; +} diff --git a/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/disable_two_factor.scss b/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/disable_two_factor.scss new file mode 100644 index 0000000000..a60b7733d2 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/disable_two_factor.scss @@ -0,0 +1,4 @@ +.disable-two-factor__description { + color: $palette-neutral-900; + margin-bottom: 24px; +} diff --git a/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/enable_with_qr_code.scss b/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/enable_with_qr_code.scss new file mode 100644 index 0000000000..09bdb8caad --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/enable_with_qr_code.scss @@ -0,0 +1,46 @@ +.enable-with-qr-code__step { + display: flex; + flex-direction: row; + gap: 16px; + padding-bottom: 24px; +} + +.enable-with-qr-code__step:not(:last-child) { + margin-bottom: 24px; + border-bottom: 1px solid $palette-neutral-200; +} + +.enable-with-qr-code__number { + font-size: 13px; + font-weight: 600; + display: flex; + width: 36px; + height: 36px; + padding: 8px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; + border-radius: 80px; + border: 1px solid #e0e3e5; + background: $palette-neutral-100; +} + +.enable-with-qr-code__step-heading { + font-size: 13px; + color: $palette-neutral-1200; + font-weight: 500; + padding-bottom: 6px; + padding-top: 10px; +} + +.enable-with-qr-code__step-description { + color: $palette-neutral-900; + margin-bottom: 24px; + line-height: 20px; +} + +.enable-with-qr-code__step-qr-code { + width: 160px; + margin-left: -16px; +} diff --git a/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/save_backup_code.scss b/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/save_backup_code.scss new file mode 100644 index 0000000000..3039481bee --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/save_backup_code.scss @@ -0,0 +1,22 @@ +.save-backup-code__description { + font-size: 13px; + color: $palette-neutral-900; + margin-bottom: 24px; +} + +.save-backup-code__subtitle { + font-size: 13px; + font-weight: 500; + margin-bottom: 12px; +} + +.save-backup-code__code { + color: $palette-neutral-900; + font-size: 12px; + border-radius: 6px; + line-height: 20px; + padding: 12px 16px; + border: 1px solid $palette-neutral-400; + background: $palette-neutral-100; + margin-bottom: 24px; +} diff --git a/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/two_factor_auth_empty.scss b/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/two_factor_auth_empty.scss new file mode 100644 index 0000000000..68ae801df1 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/two_factor_auth_empty.scss @@ -0,0 +1,13 @@ +.two-factor-auth-empty { + margin: auto; + text-align: center; +} + +.two-factor-auth-empty__icon { + display: inline-block; + padding: 12px; + margin-bottom: 24px; + border-radius: 6px; + border: 1px solid $palette-neutral-200; + box-shadow: 0 1px 2px 0 rgba(7, 8, 16, 0.1); +} diff --git a/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/two_factor_enabled.scss b/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/two_factor_enabled.scss new file mode 100644 index 0000000000..6a80a9ba72 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/settings/two_factor_auth/two_factor_enabled.scss @@ -0,0 +1,14 @@ +.two-factor-enabled__type { + display: inline-block; + font-size: 13px; + font-weight: 500; + line-height: 20px; + margin-bottom: 10px; + margin-right: 8px; +} + +.two-factor-enabled__description { + color: $palette-neutral-900; + margin-bottom: 14px; + line-height: 20px; +} diff --git a/web-frontend/modules/core/assets/scss/variables.scss b/web-frontend/modules/core/assets/scss/variables.scss index 1a1b6e631b..8ab8389e7c 100644 --- a/web-frontend/modules/core/assets/scss/variables.scss +++ b/web-frontend/modules/core/assets/scss/variables.scss @@ -85,7 +85,7 @@ $baserow-icons: 'circle-empty', 'circle-checked', 'check-square', 'formula', 'up-down-arrows', 'application', 'groups', 'text', 'timeline', 'dashboard', 'jira', 'postgresql', 'hubspot', 'separator', 'spacer', 'automation', 'sidebar', 'enlarge-row', 'bar-chart', 'line-chart', 'pie-chart', - 'donut-chart', 'dependency'; + 'donut-chart', 'dependency', 'password-check'; $grid-view-row-height-small: 33px; $grid-view-row-height-medium: 55px; diff --git a/web-frontend/modules/core/components/RadioCard.vue b/web-frontend/modules/core/components/RadioCard.vue new file mode 100644 index 0000000000..21ddee2b9f --- /dev/null +++ b/web-frontend/modules/core/components/RadioCard.vue @@ -0,0 +1,70 @@ + + + diff --git a/web-frontend/modules/core/components/auth/Login.vue b/web-frontend/modules/core/components/auth/Login.vue index 8c0a3d9976..2b0a26aa8e 100644 --- a/web-frontend/modules/core/components/auth/Login.vue +++ b/web-frontend/modules/core/components/auth/Login.vue @@ -1,8 +1,18 @@