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
2 changes: 1 addition & 1 deletion backend/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ lint: .check-dev
$(VISORT) --check --skip generated $(BACKEND_SOURCE_DIRS) $(BACKEND_TESTS_DIRS)
# TODO: make baserow command reading dotenv files
DJANGO_SETTINGS_MODULE=$(DJANGO_SETTINGS_MODULE) $(VBASEROW) makemigrations --dry-run --check
$(VBANDIT) -r --exclude src/baserow/test_utils $(BACKEND_SOURCE_DIRS)
$(VBANDIT) -r --exclude src/baserow/test_utils,src/baserow/config/settings/local.py $(BACKEND_SOURCE_DIRS)

lint-python: lint

Expand Down
7 changes: 7 additions & 0 deletions backend/src/baserow/api/two_factor_auth/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
HTTP_429_TOO_MANY_REQUESTS,
)

ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST = (
Expand All @@ -17,6 +18,12 @@
"Two-factor authentication verification failed.",
)

ERROR_RATE_LIMIT_EXCEEDED = (
"ERROR_RATE_LIMIT_EXCEEDED",
HTTP_429_TOO_MANY_REQUESTS,
"Rate limit exceeded.",
)

ERROR_WRONG_PASSWORD = (
"ERROR_WRONG_PASSWORD",
HTTP_403_FORBIDDEN,
Expand Down
18 changes: 15 additions & 3 deletions backend/src/baserow/api/two_factor_auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
)
from baserow.api.schemas import get_error_schema
from baserow.api.two_factor_auth.errors import (
ERROR_RATE_LIMIT_EXCEEDED,
ERROR_TWO_FACTOR_AUTH_ALREADY_CONFIGURED,
ERROR_TWO_FACTOR_AUTH_CANNOT_BE_CONFIGURED,
ERROR_TWO_FACTOR_AUTH_NOT_CONFIGURED,
Expand All @@ -27,7 +28,7 @@
VerifyTOTPSerializer,
)
from baserow.api.two_factor_auth.tokens import Require2faToken
from baserow.api.user.schemas import create_user_response_schema
from baserow.api.user.schemas import authenticated_user_response_schema
from baserow.api.user.serializers import log_in_user
from baserow.api.utils import DiscriminatorCustomFieldsMappingSerializer
from baserow.core.models import User
Expand All @@ -48,6 +49,8 @@
TOTPAuthProviderType,
two_factor_auth_type_registry,
)
from baserow.throttling import RateLimitExceededException, rate_limit
from baserow.throttling_types import RateLimit


class ConfigureTwoFactorAuthView(APIView):
Expand Down Expand Up @@ -181,20 +184,22 @@ class VerifyTOTPAuthView(APIView):
description=("Verifies TOTP two-factor authentication"),
request=VerifyTOTPSerializer,
responses={
200: create_user_response_schema,
200: authenticated_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"]),
429: get_error_schema(["ERROR_RATE_LIMIT_EXCEEDED"]),
},
)
@map_exceptions(
{
TwoFactorAuthTypeDoesNotExist: ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST,
VerificationFailed: ERROR_TWO_FACTOR_AUTH_VERIFICATION_FAILED,
RateLimitExceededException: ERROR_RATE_LIMIT_EXCEEDED,
}
)
@validate_body(VerifyTOTPSerializer, return_validated=True)
Expand All @@ -204,7 +209,14 @@ def post(self, request, data: dict):
Verifies TOTP two-factor authentication.
"""

TwoFactorAuthHandler().verify(TOTPAuthProviderType.type, **data)
def verify():
TwoFactorAuthHandler().verify(TOTPAuthProviderType.type, **data)

rate_limit(
rate=RateLimit.from_string("10/m"),
key=f"two_fa_verify:totp:{data.get('email', '')}",
raise_exception=True,
)(verify)()

user = User.objects.filter(email=data["email"]).first()
return_data = log_in_user(request, user)
Expand Down
25 changes: 23 additions & 2 deletions backend/src/baserow/api/user/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,37 @@
}
}

create_user_response_schema = build_object_type(
two_factor_required_response_schema = build_object_type(
{
"two_factor_auth": {
"type": "string",
"description": "The type of the two factor auth that is required to perform.",
},
"token": {
"type": "string",
"description": "The temporary token for verifying authentication using 2fa.",
},
}
)

success_create_user_response_schema = build_object_type(
{
**user_response_schema,
**access_token_schema,
**refresh_token_schema,
}
)

authenticated_user_response_schema = {
"oneOf": [
success_create_user_response_schema,
two_factor_required_response_schema,
],
}


if jwt_settings.ROTATE_REFRESH_TOKENS:
authenticate_user_schema = create_user_response_schema
authenticate_user_schema = authenticated_user_response_schema
else:
authenticate_user_schema = build_object_type(
{
Expand Down
15 changes: 11 additions & 4 deletions backend/src/baserow/api/user/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,11 @@ def log_in_user(request, user):
return data


class TwoFactorAuthRequiredSerializer(serializers.Serializer):
two_factor_auth = serializers.CharField()
token = serializers.CharField()


@extend_schema_serializer(deprecate_fields=["username"])
class TokenObtainPairWithUserSerializer(TokenObtainPairSerializer):
email = NormalizedEmailField(required=False)
Expand Down Expand Up @@ -343,10 +348,12 @@ def validate(self, attrs):
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 TwoFactorAuthRequiredSerializer(
{
"two_factor_auth": provider_type.type,
"token": str(token),
}
).data

return log_in_user(self.context["request"], self.user)

Expand Down
12 changes: 7 additions & 5 deletions backend/src/baserow/api/user/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
from .exceptions import ClientSessionIdHeaderNotSetException
from .schemas import (
authenticate_user_schema,
create_user_response_schema,
authenticated_user_response_schema,
verify_user_schema,
)
from .serializers import (
Expand Down Expand Up @@ -141,10 +141,12 @@ class ObtainJSONWebToken(TokenObtainPairView):
operation_id="token_auth",
description=(
"Authenticates an existing user based on their email and their password. "
"If successful, an access token and a refresh token will be returned."
"If successful, an access token and a refresh token will be returned. "
"If the account is protected with two-factor authentication, "
"temporary token is returned to finish the verification."
),
responses={
200: create_user_response_schema,
200: authenticated_user_response_schema,
401: get_error_schema(
[
"ERROR_INVALID_CREDENTIALS",
Expand Down Expand Up @@ -269,7 +271,7 @@ class UserView(APIView):
"account the initial workspace containing a database is created."
),
responses={
200: create_user_response_schema,
200: authenticated_user_response_schema,
400: get_error_schema(
[
"ERROR_ALREADY_EXISTS",
Expand Down Expand Up @@ -556,7 +558,7 @@ class VerifyEmailAddressView(APIView):
"request is performed by unauthenticated user."
),
responses={
200: create_user_response_schema,
200: authenticated_user_response_schema,
400: get_error_schema(
[
"ERROR_INVALID_VERIFICATION_TOKEN",
Expand Down
2 changes: 1 addition & 1 deletion backend/src/baserow/config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1049,7 +1049,7 @@ def __setitem__(self, key, value):
# The minimum amount of minutes the periodic task's "minute" interval
# supports. Self-hosters can run every minute, if they choose to.
INTEGRATIONS_PERIODIC_MINUTE_MIN = int(
os.getenv("BASEROW_INTEGRATIONS_PERIODIC_MINUTE_MIN", 1)
os.getenv("BASEROW_INTEGRATIONS_PERIODIC_MINUTE_MIN") or 1
)

TOTP_ISSUER_NAME = os.getenv("BASEROW_TOTP_ISSUER_NAME", "Baserow")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from baserow.contrib.builder.pages.handler import PageHandler
from baserow.contrib.builder.pages.models import Page
from baserow.core.app_auth_providers.registries import app_auth_provider_type_registry
from baserow.core.formula.serializers import FormulaSerializerField
from baserow.core.services.registries import service_type_registry
from baserow.core.user_sources.models import UserSource
from baserow.core.user_sources.registries import user_source_type_registry
Expand Down Expand Up @@ -100,6 +101,10 @@ class PublicElementSerializer(serializers.ModelSerializer):
def get_type(self, instance):
return element_type_registry.get_by_model(instance.specific_class).type

visibility_condition = FormulaSerializerField(
help_text=Element._meta.get_field("visibility_condition").help_text,
)

style_background_file = UserFileField(
allow_null=True,
help_text="The background image file",
Expand All @@ -118,6 +123,7 @@ class Meta:
"place_in_container",
"css_classes",
"visibility",
"visibility_condition",
"styles",
"style_border_top_color",
"style_border_top_size",
Expand Down
15 changes: 15 additions & 0 deletions backend/src/baserow/contrib/builder/api/elements/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ class ElementSerializer(serializers.ModelSerializer):
def get_type(self, instance):
return element_type_registry.get_by_model(instance.specific_class).type

visibility_condition = FormulaSerializerField(
help_text=Element._meta.get_field("visibility_condition").help_text,
)

style_background_file = UserFileField(
allow_null=True,
help_text="The background image file",
Expand All @@ -67,6 +71,7 @@ class Meta:
"place_in_container",
"css_classes",
"visibility",
"visibility_condition",
"styles",
"style_border_top_color",
"style_border_top_size",
Expand Down Expand Up @@ -132,6 +137,10 @@ class CreateElementSerializer(serializers.ModelSerializer):
validators=[image_file_validation],
)

visibility_condition = FormulaSerializerField(
help_text=Element._meta.get_field("visibility_condition").help_text,
)

class Meta:
model = Element
fields = (
Expand All @@ -142,6 +151,7 @@ class Meta:
"place_in_container",
"css_classes",
"visibility",
"visibility_condition",
"styles",
"style_border_top_color",
"style_border_top_size",
Expand Down Expand Up @@ -181,11 +191,16 @@ class UpdateElementSerializer(serializers.ModelSerializer):
validators=[image_file_validation],
)

visibility_condition = FormulaSerializerField(
help_text=Element._meta.get_field("visibility_condition").help_text,
)

class Meta:
model = Element
fields = (
"css_classes",
"visibility",
"visibility_condition",
"styles",
"style_border_top_color",
"style_border_top_size",
Expand Down
2 changes: 2 additions & 0 deletions backend/src/baserow/contrib/builder/elements/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class ElementHandler:
"parent_element_id",
"place_in_container",
"visibility",
"visibility_condition",
"css_classes",
"styles",
"style_border_top_color",
Expand Down Expand Up @@ -82,6 +83,7 @@ class ElementHandler:
"place_in_container",
"css_classes",
"visibility",
"visibility_condition",
"styles",
"style_border_top_color",
"style_border_top_size",
Expand Down
4 changes: 4 additions & 0 deletions backend/src/baserow/contrib/builder/elements/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ class ROLE_TYPES(models.TextChoices):
db_index=True,
)

visibility_condition = FormulaField(
help_text="Change element visibility depending on a formula value"
)

styles = models.JSONField(
default=dict,
help_text="The theme overrides for this element",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 5.0.14 on 2025-11-09 10:09

from django.db import migrations

import baserow.core.formula.field


class Migration(migrations.Migration):
dependencies = [
("builder", "0065_aiagentworkflowaction"),
]

operations = [
migrations.AddField(
model_name="element",
name="visibility_condition",
field=baserow.core.formula.field.FormulaField(
blank=True,
default="",
help_text="Change element visibility depending on a formula value",
null=True,
),
),
]
2 changes: 2 additions & 0 deletions backend/src/baserow/contrib/builder/types.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import List, Optional, TypedDict

from baserow.contrib.builder.pages.types import PagePathParams, PageQueryParams
from baserow.core.formula import BaserowFormulaObject
from baserow.core.integrations.types import IntegrationDictSubClass
from baserow.core.services.types import ServiceDictSubClass
from baserow.core.user_sources.types import UserSourceDictSubClass
Expand All @@ -15,6 +16,7 @@ class ElementDict(TypedDict):
place_in_container: str
css_classes: str
visibility: str
visibility_condition: BaserowFormulaObject
role_type: str
roles: list
styles: dict
Expand Down
3 changes: 3 additions & 0 deletions backend/src/baserow/core/formula/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ def _transform_db_value_to_dict(

# If the column type is "text", then we haven't yet migrated the schema.
if self.db_type(connection) == "text":
if value is None:
return BaserowFormulaObject.create("")

if isinstance(value, int):
# A small hack for our backend tests: if we
# receive an integer, we convert it to a string.
Expand Down
7 changes: 7 additions & 0 deletions backend/src/baserow/core/two_factor_auth/registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import string
from abc import ABC, abstractmethod
from base64 import b64encode
from datetime import datetime, timedelta, timezone
from io import BytesIO

from django.conf import settings
Expand Down Expand Up @@ -117,6 +118,12 @@ def configure(
raise TwoFactorAuthAlreadyConfigured

if provider and kwargs.get("code"):
secret_valid_until = provider.created_on + timedelta(minutes=30)
now = datetime.now(tz=timezone.utc)
if now > secret_valid_until:
provider.delete()
raise VerificationFailed

code = kwargs.get("code")
totp = pyotp.TOTP(provider.secret)

Expand Down
Loading
Loading