Skip to content

Commit 6813015

Browse files
authored
2fa improvements (baserow#4171)
1 parent cabf111 commit 6813015

File tree

15 files changed

+539
-31
lines changed

15 files changed

+539
-31
lines changed

backend/src/baserow/api/two_factor_auth/errors.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
HTTP_401_UNAUTHORIZED,
44
HTTP_403_FORBIDDEN,
55
HTTP_404_NOT_FOUND,
6+
HTTP_429_TOO_MANY_REQUESTS,
67
)
78

89
ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST = (
@@ -17,6 +18,12 @@
1718
"Two-factor authentication verification failed.",
1819
)
1920

21+
ERROR_RATE_LIMIT_EXCEEDED = (
22+
"ERROR_RATE_LIMIT_EXCEEDED",
23+
HTTP_429_TOO_MANY_REQUESTS,
24+
"Rate limit exceeded.",
25+
)
26+
2027
ERROR_WRONG_PASSWORD = (
2128
"ERROR_WRONG_PASSWORD",
2229
HTTP_403_FORBIDDEN,

backend/src/baserow/api/two_factor_auth/views.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414
from baserow.api.schemas import get_error_schema
1515
from baserow.api.two_factor_auth.errors import (
16+
ERROR_RATE_LIMIT_EXCEEDED,
1617
ERROR_TWO_FACTOR_AUTH_ALREADY_CONFIGURED,
1718
ERROR_TWO_FACTOR_AUTH_CANNOT_BE_CONFIGURED,
1819
ERROR_TWO_FACTOR_AUTH_NOT_CONFIGURED,
@@ -27,7 +28,7 @@
2728
VerifyTOTPSerializer,
2829
)
2930
from baserow.api.two_factor_auth.tokens import Require2faToken
30-
from baserow.api.user.schemas import create_user_response_schema
31+
from baserow.api.user.schemas import authenticated_user_response_schema
3132
from baserow.api.user.serializers import log_in_user
3233
from baserow.api.utils import DiscriminatorCustomFieldsMappingSerializer
3334
from baserow.core.models import User
@@ -48,6 +49,8 @@
4849
TOTPAuthProviderType,
4950
two_factor_auth_type_registry,
5051
)
52+
from baserow.throttling import RateLimitExceededException, rate_limit
53+
from baserow.throttling_types import RateLimit
5154

5255

5356
class ConfigureTwoFactorAuthView(APIView):
@@ -181,20 +184,22 @@ class VerifyTOTPAuthView(APIView):
181184
description=("Verifies TOTP two-factor authentication"),
182185
request=VerifyTOTPSerializer,
183186
responses={
184-
200: create_user_response_schema,
187+
200: authenticated_user_response_schema,
185188
400: get_error_schema(
186189
[
187190
"ERROR_REQUEST_BODY_VALIDATION",
188191
]
189192
),
190193
401: get_error_schema(["ERROR_TWO_FACTOR_AUTH_VERIFICATION_FAILED"]),
191194
404: get_error_schema(["ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST"]),
195+
429: get_error_schema(["ERROR_RATE_LIMIT_EXCEEDED"]),
192196
},
193197
)
194198
@map_exceptions(
195199
{
196200
TwoFactorAuthTypeDoesNotExist: ERROR_TWO_FACTOR_AUTH_TYPE_DOES_NOT_EXIST,
197201
VerificationFailed: ERROR_TWO_FACTOR_AUTH_VERIFICATION_FAILED,
202+
RateLimitExceededException: ERROR_RATE_LIMIT_EXCEEDED,
198203
}
199204
)
200205
@validate_body(VerifyTOTPSerializer, return_validated=True)
@@ -204,7 +209,14 @@ def post(self, request, data: dict):
204209
Verifies TOTP two-factor authentication.
205210
"""
206211

207-
TwoFactorAuthHandler().verify(TOTPAuthProviderType.type, **data)
212+
def verify():
213+
TwoFactorAuthHandler().verify(TOTPAuthProviderType.type, **data)
214+
215+
rate_limit(
216+
rate=RateLimit.from_string("10/m"),
217+
key=f"two_fa_verify:totp:{data.get('email', '')}",
218+
raise_exception=True,
219+
)(verify)()
208220

209221
user = User.objects.filter(email=data["email"]).first()
210222
return_data = log_in_user(request, user)

backend/src/baserow/api/user/schemas.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,37 @@
5656
}
5757
}
5858

59-
create_user_response_schema = build_object_type(
59+
two_factor_required_response_schema = build_object_type(
60+
{
61+
"two_factor_auth": {
62+
"type": "string",
63+
"description": "The type of the two factor auth that is required to perform.",
64+
},
65+
"token": {
66+
"type": "string",
67+
"description": "The temporary token for verifying authentication using 2fa.",
68+
},
69+
}
70+
)
71+
72+
success_create_user_response_schema = build_object_type(
6073
{
6174
**user_response_schema,
6275
**access_token_schema,
6376
**refresh_token_schema,
6477
}
6578
)
6679

80+
authenticated_user_response_schema = {
81+
"oneOf": [
82+
success_create_user_response_schema,
83+
two_factor_required_response_schema,
84+
],
85+
}
86+
87+
6788
if jwt_settings.ROTATE_REFRESH_TOKENS:
68-
authenticate_user_schema = create_user_response_schema
89+
authenticate_user_schema = authenticated_user_response_schema
6990
else:
7091
authenticate_user_schema = build_object_type(
7192
{

backend/src/baserow/api/user/serializers.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,11 @@ def log_in_user(request, user):
307307
return data
308308

309309

310+
class TwoFactorAuthRequiredSerializer(serializers.Serializer):
311+
two_factor_auth = serializers.CharField()
312+
token = serializers.CharField()
313+
314+
310315
@extend_schema_serializer(deprecate_fields=["username"])
311316
class TokenObtainPairWithUserSerializer(TokenObtainPairSerializer):
312317
email = NormalizedEmailField(required=False)
@@ -343,10 +348,12 @@ def validate(self, attrs):
343348
if provider_type.is_enabled(twofa_provider):
344349
token = TwoFactorAccessToken.for_user(self.user)
345350
token.set_exp(lifetime=timedelta(minutes=2))
346-
return {
347-
"two_factor_auth": provider_type.type,
348-
"token": str(token),
349-
}
351+
return TwoFactorAuthRequiredSerializer(
352+
{
353+
"two_factor_auth": provider_type.type,
354+
"token": str(token),
355+
}
356+
).data
350357

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

backend/src/baserow/api/user/views.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@
102102
from .exceptions import ClientSessionIdHeaderNotSetException
103103
from .schemas import (
104104
authenticate_user_schema,
105-
create_user_response_schema,
105+
authenticated_user_response_schema,
106106
verify_user_schema,
107107
)
108108
from .serializers import (
@@ -141,10 +141,12 @@ class ObtainJSONWebToken(TokenObtainPairView):
141141
operation_id="token_auth",
142142
description=(
143143
"Authenticates an existing user based on their email and their password. "
144-
"If successful, an access token and a refresh token will be returned."
144+
"If successful, an access token and a refresh token will be returned. "
145+
"If the account is protected with two-factor authentication, "
146+
"temporary token is returned to finish the verification."
145147
),
146148
responses={
147-
200: create_user_response_schema,
149+
200: authenticated_user_response_schema,
148150
401: get_error_schema(
149151
[
150152
"ERROR_INVALID_CREDENTIALS",
@@ -269,7 +271,7 @@ class UserView(APIView):
269271
"account the initial workspace containing a database is created."
270272
),
271273
responses={
272-
200: create_user_response_schema,
274+
200: authenticated_user_response_schema,
273275
400: get_error_schema(
274276
[
275277
"ERROR_ALREADY_EXISTS",
@@ -556,7 +558,7 @@ class VerifyEmailAddressView(APIView):
556558
"request is performed by unauthenticated user."
557559
),
558560
responses={
559-
200: create_user_response_schema,
561+
200: authenticated_user_response_schema,
560562
400: get_error_schema(
561563
[
562564
"ERROR_INVALID_VERIFICATION_TOKEN",

backend/src/baserow/core/two_factor_auth/registries.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import string
44
from abc import ABC, abstractmethod
55
from base64 import b64encode
6+
from datetime import datetime, timedelta, timezone
67
from io import BytesIO
78

89
from django.conf import settings
@@ -117,6 +118,12 @@ def configure(
117118
raise TwoFactorAuthAlreadyConfigured
118119

119120
if provider and kwargs.get("code"):
121+
secret_valid_until = provider.created_on + timedelta(minutes=30)
122+
now = datetime.now(tz=timezone.utc)
123+
if now > secret_valid_until:
124+
provider.delete()
125+
raise VerificationFailed
126+
120127
code = kwargs.get("code")
121128
totp = pyotp.TOTP(provider.secret)
122129

backend/tests/baserow/api/users/test_token_auth.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from baserow.core.user.handler import UserHandler
2121
from baserow.core.user.utils import generate_session_tokens_for_user
2222
from baserow.core.utils import generate_hash
23+
from baserow.test_utils.helpers import AnyStr
2324

2425
User = get_user_model()
2526

@@ -278,6 +279,24 @@ def test_token_password_auth_disabled(api_client, data_fixture):
278279
}
279280

280281

282+
@pytest.mark.django_db
283+
def test_token_auth_2fa_required(api_client, data_fixture):
284+
data_fixture.create_password_provider(enabled=True)
285+
user, token = data_fixture.create_user_and_token(
286+
email="test@localhost", password="test"
287+
)
288+
data_fixture.configure_totp(user)
289+
290+
response = api_client.post(
291+
reverse("api:user:token_auth"),
292+
{"email": "test@localhost", "password": "test"},
293+
format="json",
294+
)
295+
296+
assert response.status_code == HTTP_200_OK, response.json()
297+
assert response.json() == {"two_factor_auth": "totp", "token": AnyStr()}
298+
299+
281300
@pytest.mark.django_db
282301
def test_token_password_auth_disabled_superadmin(api_client, data_fixture):
283302
data_fixture.create_password_provider(enabled=False)

0 commit comments

Comments
 (0)