Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand All @@ -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
# BASEROW_ENTERPRISE_ASSISTANT_LLM_MODEL=groq/openai/gpt-oss-120b # Needs GROQ_API_KEY env var set too
4 changes: 3 additions & 1 deletion backend/requirements/base.in
Original file line number Diff line number Diff line change
Expand Up @@ -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]
litellm==1.77.7 # Pinned to avoid bug in 1.75.3 requiring litellm[proxy]
pyotp==2.9.0
qrcode==8.2
3 changes: 3 additions & 0 deletions backend/requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -623,6 +625,7 @@ pyyaml==6.0.2
# optuna
# uvicorn
redis==5.2.1
qrcode==8.2
# via
# -r base.in
# celery-redbeat
Expand Down
Empty file.
42 changes: 42 additions & 0 deletions backend/src/baserow/api/two_factor_auth/errors.py
Original file line number Diff line number Diff line change
@@ -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",
)
39 changes: 39 additions & 0 deletions backend/src/baserow/api/two_factor_auth/serializers.py
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 29 additions & 0 deletions backend/src/baserow/api/two_factor_auth/tokens.py
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions backend/src/baserow/api/two_factor_auth/urls.py
Original file line number Diff line number Diff line change
@@ -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"),
]
215 changes: 215 additions & 0 deletions backend/src/baserow/api/two_factor_auth/views.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading