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 @@ + + + + + + + + {{ label }} + + {{ sideLabel }} + + + + + + + + + + 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 @@ - + + - + @@ -43,6 +53,7 @@ settings.allow_reset_password && !passwordLoginHidden " @success="success" + @two-factor-auth="setTwoFactorRequired" @email-not-verified="emailNotVerified" > @@ -70,9 +81,11 @@ import { isRelativeUrl, addQueryParamsToRedirectUrl, } from '@baserow/modules/core/utils/url' +import TOTPLogin from '@baserow/modules/core/components/auth/TOTPLogin' export default { components: { + TOTPLogin, PasswordLogin, LoginButtons, LangPicker, @@ -116,6 +129,10 @@ export default { passwordLoginHiddenIfDisabled: true, displayEmailNotVerified: false, emailToVerify: null, + twoFactorComponent: null, + twoFactorRequired: false, + twoFactorEmail: null, + twoFaToken: null, } }, computed: { @@ -167,6 +184,13 @@ export default { this.displayEmailNotVerified = true this.emailToVerify = email }, + setTwoFactorRequired(type, email, token) { + const twoFaType = this.$registry.get('twoFactorAuth', type) + this.twoFactorComponent = twoFaType.loginComponent + this.twoFactorRequired = true + this.twoFactorEmail = email + this.twoFaToken = token + }, }, } diff --git a/web-frontend/modules/core/components/auth/PasswordLogin.vue b/web-frontend/modules/core/components/auth/PasswordLogin.vue index 0a8681c9e6..77e0fae52a 100644 --- a/web-frontend/modules/core/components/auth/PasswordLogin.vue +++ b/web-frontend/modules/core/components/auth/PasswordLogin.vue @@ -187,6 +187,15 @@ export default { email: this.values.email, password: this.values.password, }) + if (data.two_factor_auth) { + this.$emit( + 'two-factor-auth', + data.two_factor_auth, + this.values.email, + data.token + ) + return + } // If there is an invitation we can immediately accept that one after the user // successfully signs in. @@ -238,6 +247,8 @@ export default { } else { throw error } + } finally { + this.loading = false } }, }, diff --git a/web-frontend/modules/core/components/auth/TOTPLogin.vue b/web-frontend/modules/core/components/auth/TOTPLogin.vue new file mode 100644 index 0000000000..c05a02a74a --- /dev/null +++ b/web-frontend/modules/core/components/auth/TOTPLogin.vue @@ -0,0 +1,185 @@ + + + + + + + + + + + + {{ $t('totpLogin.backupCodesTitle') }} + + + + {{ $t('totpLogin.backupCodesDescription') }} + + + + + {{ v$.values.backupCode.$errors[0]?.$message }} + + + {{ $t('totpLogin.authenticate') }} + + + + {{ $t('totpLogin.goBack') }} + + + + + + + {{ $t('totpLogin.totpTitle') }} + + + {{ $t('totpLogin.totpDescription') }} + + + + {{ $t('totpLogin.verify') }} + + + + + {{ $t('totpLogin.useBackupCode') }} + + + + + + + + + diff --git a/web-frontend/modules/core/components/settings/TwoFactorAuthSettings.vue b/web-frontend/modules/core/components/settings/TwoFactorAuthSettings.vue new file mode 100644 index 0000000000..40200c3310 --- /dev/null +++ b/web-frontend/modules/core/components/settings/TwoFactorAuthSettings.vue @@ -0,0 +1,91 @@ + + + {{ $t('twoFactorAuthSettings.title') }} + + + + + + + + + + + + diff --git a/web-frontend/modules/core/components/settings/twoFactorAuth/AuthCodeInput.vue b/web-frontend/modules/core/components/settings/twoFactorAuth/AuthCodeInput.vue new file mode 100644 index 0000000000..a0bfcf54bb --- /dev/null +++ b/web-frontend/modules/core/components/settings/twoFactorAuth/AuthCodeInput.vue @@ -0,0 +1,202 @@ + + + + + + + + + + + + diff --git a/web-frontend/modules/core/components/settings/twoFactorAuth/DisableTwoFactorAuth.vue b/web-frontend/modules/core/components/settings/twoFactorAuth/DisableTwoFactorAuth.vue new file mode 100644 index 0000000000..627b6e7982 --- /dev/null +++ b/web-frontend/modules/core/components/settings/twoFactorAuth/DisableTwoFactorAuth.vue @@ -0,0 +1,111 @@ + + + {{ $t('disableTwoFactorAuth.title') }} + + {{ $t('disableTwoFactorAuth.description') }} + + + + + + + + + + + {{ v$.values.password.$errors[0]?.$message }} + + + + + + {{ $t('disableTwoFactorAuth.cancel') }} + + + {{ $t('disableTwoFactorAuth.disable') }} + + + + + + + diff --git a/web-frontend/modules/core/components/settings/twoFactorAuth/EnableTOTP.vue b/web-frontend/modules/core/components/settings/twoFactorAuth/EnableTOTP.vue new file mode 100644 index 0000000000..e2cfdd540c --- /dev/null +++ b/web-frontend/modules/core/components/settings/twoFactorAuth/EnableTOTP.vue @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/web-frontend/modules/core/components/settings/twoFactorAuth/EnableTwoFactorOptions.vue b/web-frontend/modules/core/components/settings/twoFactorAuth/EnableTwoFactorOptions.vue new file mode 100644 index 0000000000..0905c63f2a --- /dev/null +++ b/web-frontend/modules/core/components/settings/twoFactorAuth/EnableTwoFactorOptions.vue @@ -0,0 +1,48 @@ + + + + + {{ option.description }} + + + + {{ + $t('enableTwoFactorOptions.cancel') + }} + {{ + $t('enableTwoFactorOptions.continue') + }} + + + + + diff --git a/web-frontend/modules/core/components/settings/twoFactorAuth/EnableWithQRCode.vue b/web-frontend/modules/core/components/settings/twoFactorAuth/EnableWithQRCode.vue new file mode 100644 index 0000000000..a9deab89b5 --- /dev/null +++ b/web-frontend/modules/core/components/settings/twoFactorAuth/EnableWithQRCode.vue @@ -0,0 +1,92 @@ + + + + 1 + + + {{ $t('enableWithQRCode.scanQRCode') }} + + + {{ $t('enableWithQRCode.scanQRCodeDescription') }} + + + + + + + 2 + + + {{ $t('enableWithQRCode.enterCode') }} + + + {{ $t('enableWithQRCode.enterCodeDescription') }} + + + + + + + + diff --git a/web-frontend/modules/core/components/settings/twoFactorAuth/SaveBackupCode.vue b/web-frontend/modules/core/components/settings/twoFactorAuth/SaveBackupCode.vue new file mode 100644 index 0000000000..155aad4299 --- /dev/null +++ b/web-frontend/modules/core/components/settings/twoFactorAuth/SaveBackupCode.vue @@ -0,0 +1,51 @@ + + + + {{ $t('saveBackupCode.description') }} + + + {{ $t('saveBackupCode.backupCodes') }} + + + + {{ code }} + + + + {{ + $t('saveBackupCode.copy') + }} + {{ + $t('saveBackupCode.continue') + }} + + + + + diff --git a/web-frontend/modules/core/components/settings/twoFactorAuth/TwoFactorAuthEmpty.vue b/web-frontend/modules/core/components/settings/twoFactorAuth/TwoFactorAuthEmpty.vue new file mode 100644 index 0000000000..807f6e7a60 --- /dev/null +++ b/web-frontend/modules/core/components/settings/twoFactorAuth/TwoFactorAuthEmpty.vue @@ -0,0 +1,31 @@ + + + + + + + + {{ $t('twoFactorAuthEmpty.title') }} + {{ $t('twoFactorAuthEmpty.description') }} + {{ + $t('twoFactorAuthEmpty.enable') + }} + + + {{ $t('twoFactorAuthEmpty.notAllowedTitle') }} + {{ $t('twoFactorAuthEmpty.notAllowedDescription') }} + + + + + + diff --git a/web-frontend/modules/core/components/settings/twoFactorAuth/TwoFactorEnabled.vue b/web-frontend/modules/core/components/settings/twoFactorAuth/TwoFactorEnabled.vue new file mode 100644 index 0000000000..044de781db --- /dev/null +++ b/web-frontend/modules/core/components/settings/twoFactorAuth/TwoFactorEnabled.vue @@ -0,0 +1,39 @@ + + + + {{ providerName }}{{ + $t('twoFactorEnabled.enabled') + }} + + + {{ providerEnabledDescription }} + + {{ + $t('twoFactorEnabled.disable') + }} + + + + diff --git a/web-frontend/modules/core/locales/en.json b/web-frontend/modules/core/locales/en.json index 72d2ce27ae..69ccd24860 100644 --- a/web-frontend/modules/core/locales/en.json +++ b/web-frontend/modules/core/locales/en.json @@ -71,6 +71,52 @@ "changedDescription": "Your account information has been changed.", "submitButton": "Update account" }, + "twoFactorAuthSettings": { + "title": "Two-factor authentication", + "loadingError": "Could not load two-factor configuration." + }, + "disableTwoFactorAuth": { + "title": "Are you sure you want to disable 2FA?", + "description": "Your account will lose an extra layer of security. If someone finds out your password, they might be able to log in to your account.", + "cancel": "Leave it on", + "disable": "Disable", + "successTitle": "Two-factor authentication has been disabled", + "errorWrongPasswordTitle": "Wrong password", + "errorWrongPasswordMessage": "The password entered doesn't match your password." + }, + "enableTwoFactorOptions": { + "cancel": "Cancel", + "continue": "Continue" + }, + "saveBackupCode": { + "description": "If you lose access to your authenticator app or phone and can’t receive or generate authentication codes, you can use this backup. You can only use it once. Make sure you write it down or copy it into a safe place so that you can access it without logging in.", + "backupCodes": "Backup codes", + "copy": "Copy", + "continue": "Continue", + "backupCodesCopiedTitle": "Copied!", + "backupCodesCopiedMessage": "Backup codes copied to clipboard." + }, + "totpAuthType": { + "name": "Authenticator app", + "description": "Use an app to get two-factor authentication codes. We recommend using apps such as Google Authenticator, Authy and Microsoft Authenticator.", + "enabledDescription": "You'll receive verification codes via an authenticator app. To set up different app or method, simply disable 2FA and setup again.", + "sideLabel": "Recommended" + }, + "twoFactorEnabled": { + "enabled": "Enabled", + "disable": "Disable 2FA" + }, + "totpLogin": { + "backupCodesTitle": "Enter backup code", + "backupCodesDescription": "Log in with your single-use backup code.", + "authenticate": "Authenticate", + "goBack": "Go back", + "totpTitle": "Two-factor authentication", + "totpDescription": "Enter the code from your authenticator app.", + "verify": "Verify", + "useBackupCode": "Use backup code", + "verificationFailed": "Verification failed" + }, "settingsModal": { "title": "My settings" }, @@ -1008,5 +1054,21 @@ "methodsOptionDescription": "Controls which HTTP methods are allowed for this webhook. Excluding GET reduces the chance of the webhook being triggered accidentally.", "methodsOptionAll": "All", "methodsOptionExcludeGet": "Exclude GET" + }, + "twoFactorAuthEmpty": { + "title": "You have not yet enabled 2FA", + "description": "Add an extra layer of security to your account.", + "enable": "Enable 2FA", + "notAllowedTitle": "2FA not enabled", + "notAllowedDescription": "Adding 2FA is only possible to password-based accounts." + }, + "enableWithQRCode": { + "scanQRCode": "Scan QR code", + "scanQRCodeDescription": "Scan the code with an app like Google Authenticator, Authy or Microsoft Authenticator.", + "enterCode": "Enter the code shown", + "enterCodeDescription": "Enter a 6-digit code shown by the app to confirm that you have set it up correctly.", + "verificationFailed": "Verification failed", + "provisioningFailed": "Provisioning failed", + "checkSuccess": "Successfully enabled two-factor authentication" } } diff --git a/web-frontend/modules/core/pages/totp.vue b/web-frontend/modules/core/pages/totp.vue new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web-frontend/modules/core/plugin.js b/web-frontend/modules/core/plugin.js index 2dbb0e66e4..ac4958fc79 100644 --- a/web-frontend/modules/core/plugin.js +++ b/web-frontend/modules/core/plugin.js @@ -17,6 +17,7 @@ import { EmailNotificationsSettingsType, MCPEndpointSettingsType, DeleteAccountSettingsType, + TwoFactorAuthSettingsType, } from '@baserow/modules/core/settingsTypes' import { GenerativeAIWorkspaceSettingsType } from '@baserow/modules/core/workspaceSettingsTypes' import { @@ -65,6 +66,8 @@ import { } from '@baserow/modules/core/onboardingTypes' import { SidebarGuidedTourType } from '@baserow/modules/core/guidedTourTypes' +import { TOTPAuthType } from '@baserow/modules/core/twoFactorAuthTypes' + import settingsStore from '@baserow/modules/core/store/settings' import applicationStore from '@baserow/modules/core/store/application' import authProviderStore from '@baserow/modules/core/store/authProvider' @@ -179,6 +182,7 @@ export default (context, inject) => { registry.register('settings', new EmailNotificationsSettingsType(context)) registry.register('settings', new MCPEndpointSettingsType(context)) registry.register('settings', new DeleteAccountSettingsType(context)) + registry.register('settings', new TwoFactorAuthSettingsType(context)) registry.register( 'workspaceSettings', @@ -323,6 +327,8 @@ export default (context, inject) => { new BaserowVersionUpgradeNotificationType(context) ) + registry.register('twoFactorAuth', new TOTPAuthType(context)) + registry.register('onboarding', new TeamOnboardingType(context)) registry.register('onboarding', new MoreOnboardingType(context)) registry.register('onboarding', new WorkspaceOnboardingType(context)) diff --git a/web-frontend/modules/core/plugins/global.js b/web-frontend/modules/core/plugins/global.js index 629f1ba413..b1ffdd787b 100644 --- a/web-frontend/modules/core/plugins/global.js +++ b/web-frontend/modules/core/plugins/global.js @@ -11,6 +11,7 @@ import ProgressBar from '@baserow/modules/core/components/ProgressBar' import Checkbox from '@baserow/modules/core/components/Checkbox' import Radio from '@baserow/modules/core/components/Radio' import RadioGroup from '@baserow/modules/core/components/RadioGroup' +import RadioCard from '@baserow/modules/core/components/RadioCard' import Scrollbars from '@baserow/modules/core/components/Scrollbars' import Error from '@baserow/modules/core/components/Error' import SwitchInput from '@baserow/modules/core/components/SwitchInput' @@ -74,6 +75,7 @@ function setupVue(Vue) { Vue.component('Checkbox', Checkbox) Vue.component('Radio', Radio) Vue.component('RadioGroup', RadioGroup) + Vue.component('RadioCard', RadioCard) Vue.component('Scrollbars', Scrollbars) Vue.component('Alert', Alert) Vue.component('Error', Error) diff --git a/web-frontend/modules/core/services/twoFactorAuth.js b/web-frontend/modules/core/services/twoFactorAuth.js new file mode 100644 index 0000000000..1864fc52ba --- /dev/null +++ b/web-frontend/modules/core/services/twoFactorAuth.js @@ -0,0 +1,24 @@ +export default (client) => { + return { + configure(type, params) { + return client.post('/two-factor-auth/configuration/', { type, ...params }) + }, + getConfiguration() { + return client.get('/two-factor-auth/configuration/') + }, + disable(password) { + return client.post('/two-factor-auth/disable/', { password }) + }, + verify(type, email, token, params) { + return client.post( + '/two-factor-auth/verify/', + { type, email, ...params }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ) + }, + } +} diff --git a/web-frontend/modules/core/settingsTypes.js b/web-frontend/modules/core/settingsTypes.js index bb8f0f656f..f2923a1db4 100644 --- a/web-frontend/modules/core/settingsTypes.js +++ b/web-frontend/modules/core/settingsTypes.js @@ -4,6 +4,7 @@ import AccountSettings from '@baserow/modules/core/components/settings/AccountSe import DeleteAccountSettings from '@baserow/modules/core/components/settings/DeleteAccountSettings' import EmailNotifications from '@baserow/modules/core/components/settings/EmailNotifications' import McpEndpointSettings from '@baserow/modules/core/components/settings/McpEndpointSettings.vue' +import TwoFactorAuthSettings from '@baserow/modules/core/components/settings/TwoFactorAuthSettings.vue' /** * All settings types will be added to the settings modal. @@ -135,6 +136,25 @@ export class EmailNotificationsSettingsType extends SettingsType { } } +export class TwoFactorAuthSettingsType extends SettingsType { + static getType() { + return 'two-factor-auth' + } + + getIconClass() { + return 'baserow-icon-password-check' + } + + getName() { + const { i18n } = this.app + return i18n.t('settingType.twoFactorAuth') + } + + getComponent() { + return TwoFactorAuthSettings + } +} + export class MCPEndpointSettingsType extends SettingsType { static getType() { return 'mcp-endpoint' diff --git a/web-frontend/modules/core/store/auth.js b/web-frontend/modules/core/store/auth.js index 9a021b0e08..149843ef51 100644 --- a/web-frontend/modules/core/store/auth.js +++ b/web-frontend/modules/core/store/auth.js @@ -138,18 +138,31 @@ export const mutations = { export const actions = { /** - * Authenticate a user by his email and password. If successful commit the - * token to the state and start the refresh timeout to stay authenticated. + * Authenticate a user by his email and password. */ async login({ getters, dispatch }, { email, password }) { const { data } = await AuthService(this.$client).login(email, password) - dispatch('setUserData', data) - - if (!getters.getPreventSetToken) { - setToken(this.app, getters.refreshToken) - setUserSessionCookie(this.app, getters.signedUserSession) + return dispatch('loginWithData', { data }) + }, + /** + * Authenticate a user by data returned from an auth endpoint. + * If successful, commit the token to the state and start the refresh + * timeout to stay authenticated. + */ + loginWithData({ getters, dispatch }, { data }) { + if (data.user) { + dispatch('setUserData', data) + if (!getters.getPreventSetToken) { + setToken(this.app, getters.refreshToken) + setUserSessionCookie(this.app, getters.signedUserSession) + } + return data.user + } else if (data.two_factor_auth) { + return { + two_factor_auth: data.two_factor_auth, + token: data.token, + } } - return data.user }, /** * Register a new user and immediately authenticate. If successful commit the diff --git a/web-frontend/modules/core/twoFactorAuthTypes.js b/web-frontend/modules/core/twoFactorAuthTypes.js new file mode 100644 index 0000000000..86d43a00da --- /dev/null +++ b/web-frontend/modules/core/twoFactorAuthTypes.js @@ -0,0 +1,84 @@ +import { Registerable } from '@baserow/modules/core/registry' +import TOTPLogin from '@baserow/modules/core/components/auth/TOTPLogin' +import EnableTOTP from '@baserow/modules/core/components/settings/twoFactorAuth/EnableTOTP' + +export class TwoFactorAuthType extends Registerable { + get name() { + throw new Error('Must be set on the type.') + } + + /** + * Returns a description of the given auth type + */ + get description() { + return '' + } + + /** + * Returns a description for the enabled screen + */ + get enabledDescription() { + return '' + } + + /** + * Returns side label to be used when selecting + * providers. + */ + get sideLabel() { + return null + } + + /** + * The component to setup the auth type. + */ + get settingsComponent() { + return null + } + + /** + * The component to show during + * the login flow. + */ + get loginComponent() { + return null + } + + getOrder() { + return 0 + } +} + +export class TOTPAuthType extends TwoFactorAuthType { + static getType() { + return 'totp' + } + + get name() { + return this.app.i18n.t('totpAuthType.name') + } + + get description() { + return this.app.i18n.t('totpAuthType.description') + } + + get enabledDescription() { + return this.app.i18n.t('totpAuthType.enabledDescription') + } + + get sideLabel() { + return this.app.i18n.t('totpAuthType.sideLabel') + } + + get settingsComponent() { + return EnableTOTP + } + + get loginComponent() { + return TOTPLogin + } + + getOrder() { + return 0 + } +}
+ {{ $t('totpLogin.backupCodesDescription') }} +
+ {{ $t('totpLogin.totpDescription') }} +
+ {{ $t('saveBackupCode.description') }} +
{{ $t('twoFactorAuthEmpty.description') }}
{{ $t('twoFactorAuthEmpty.notAllowedDescription') }}