diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 591616b57f..4c68f6fa9a 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -1,16 +1,21 @@ from allauth.account.models import EmailAddress from base.utils import get_user_by_email +from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import User from participants.utils import ( has_participated_in_require_complete_profile_challenge, ) +from rest_auth.registration.serializers import RegisterSerializer from rest_auth.serializers import PasswordResetSerializer from rest_framework import serializers +from rest_framework.authtoken.serializers import AuthTokenSerializer from rest_framework.exceptions import ValidationError from .models import JwtToken, Profile +PASSWORD_MAX_LENGTH = getattr(settings, "PASSWORD_MAX_LENGTH", 128) + LOCKED_PROFILE_FIELDS = [ "first_name", "last_name", @@ -337,6 +342,29 @@ def create(self, validated_data): return email_address +class CustomRegisterSerializer(RegisterSerializer): + """Registration serializer with bounded password field lengths.""" + + password1 = serializers.CharField( + write_only=True, max_length=PASSWORD_MAX_LENGTH + ) + password2 = serializers.CharField( + write_only=True, max_length=PASSWORD_MAX_LENGTH + ) + + +class BoundedAuthTokenSerializer(AuthTokenSerializer): + """Login serializer with bounded password field length.""" + + password = serializers.CharField( + label="Password", + style={"input_type": "password"}, + write_only=True, + trim_whitespace=False, + max_length=PASSWORD_MAX_LENGTH, + ) + + class CustomPasswordResetSerializer(PasswordResetSerializer): """ Send password reset emails only for eligible accounts while always diff --git a/apps/accounts/throttles.py b/apps/accounts/throttles.py index 5842fb2739..c2fafdc420 100644 --- a/apps/accounts/throttles.py +++ b/apps/accounts/throttles.py @@ -41,6 +41,23 @@ def get_cache_key(self, request, view): return self.cache_format % {"scope": self.scope, "ident": email} +class RegistrationIPThrottle(_ThrottlingCacheMixin, SimpleRateThrottle): + """ + Limit registration requests to 10/hour per client IP address. + + Requires REST_FRAMEWORK["NUM_PROXIES"] to be set when deployed behind + trusted reverse proxies so client IPs are derived correctly. + """ + + scope = "registration_ip" + + def get_cache_key(self, request, view): + return self.cache_format % { + "scope": self.scope, + "ident": self.get_ident(request), + } + + class PasswordResetIPThrottle(_ThrottlingCacheMixin, SimpleRateThrottle): """ Limit password reset requests to 10/hour per client IP address. diff --git a/apps/accounts/validators.py b/apps/accounts/validators.py new file mode 100644 index 0000000000..52a92e500d --- /dev/null +++ b/apps/accounts/validators.py @@ -0,0 +1,31 @@ +from django.conf import settings +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ + + +class MaximumLengthValidator: + """ + Reject passwords longer than PASSWORD_MAX_LENGTH to prevent resource + exhaustion from oversized password payloads. + """ + + def __init__(self, max_length=None): + self.max_length = max_length or getattr( + settings, "PASSWORD_MAX_LENGTH", 128 + ) + + def validate(self, password, user=None): + if len(password) > self.max_length: + raise ValidationError( + _( + "This password is longer than the maximum of " + "%(max_length)d characters." + ), + code="password_too_long", + params={"max_length": self.max_length}, + ) + + def get_help_text(self): + return _( + "Your password must be at most %(max_length)d characters." + ) % {"max_length": self.max_length} diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 0cd63c26a7..28a7e3783d 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -15,7 +15,10 @@ throttle_classes, ) from rest_framework.response import Response +from rest_framework.status import HTTP_400_BAD_REQUEST from rest_framework.throttling import UserRateThrottle +from rest_framework_expiring_authtoken.models import ExpiringToken +from rest_framework_expiring_authtoken.views import ObtainExpiringAuthToken from rest_framework_simplejwt.authentication import JWTAuthentication from rest_framework_simplejwt.exceptions import TokenError from rest_framework_simplejwt.token_blacklist.models import OutstandingToken @@ -24,10 +27,15 @@ from .authentication import ExpiringTokenAuthentication from .models import JwtToken from .permissions import HasVerifiedEmail -from .serializers import JwtTokenSerializer, UpdateEmailSerializer +from .serializers import ( + BoundedAuthTokenSerializer, + JwtTokenSerializer, + UpdateEmailSerializer, +) from .throttles import ( PasswordResetEmailThrottle, PasswordResetIPThrottle, + RegistrationIPThrottle, ResendEmailThrottle, ) @@ -40,6 +48,31 @@ ) +class SafeObtainExpiringAuthToken(ObtainExpiringAuthToken): + """Login view with bounded password field length.""" + + def post(self, request): + serializer = BoundedAuthTokenSerializer(data=request.data) + + if serializer.is_valid(): + token, _ = ExpiringToken.objects.get_or_create( + user=serializer.validated_data["user"] + ) + + if token.expired(): + token.delete() + token = ExpiringToken.objects.create( + user=serializer.validated_data["user"] + ) + + return Response({"token": token.key}) + + return Response(serializer.errors, status=HTTP_400_BAD_REQUEST) + + +safe_obtain_expiring_auth_token = SafeObtainExpiringAuthToken.as_view() + + class SafePasswordResetView(PasswordResetView): """Password reset view that returns a generic response to prevent email enumeration.""" @@ -65,6 +98,8 @@ class SafeRegisterView(RegisterView): slip past the check. This view acts as a safety net for that case. """ + throttle_classes = (RegistrationIPThrottle,) + def create(self, request, *args, **kwargs): try: return super().create(request, *args, **kwargs) diff --git a/apps/challenges/serializers.py b/apps/challenges/serializers.py index 90fbf3bcba..d20cd61d92 100644 --- a/apps/challenges/serializers.py +++ b/apps/challenges/serializers.py @@ -5,6 +5,7 @@ DEFAULT_WORKER_PYTHON_VERSION, SUPPORTED_WORKER_PYTHON_VERSIONS, ) +from django.conf import settings from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError as DjangoValidationError from hosts.serializers import ChallengeHostTeamSerializer @@ -615,7 +616,10 @@ class ChallengeInvitationRegisterSerializer(serializers.Serializer): last_name = serializers.CharField( max_length=150, required=False, allow_blank=True ) - password = serializers.CharField(write_only=True) + password = serializers.CharField( + write_only=True, + max_length=getattr(settings, "PASSWORD_MAX_LENGTH", 128), + ) def validate(self, attrs): user = self.context.get("user") diff --git a/evalai/urls.py b/evalai/urls.py index 431e390455..42f993667f 100644 --- a/evalai/urls.py +++ b/evalai/urls.py @@ -14,7 +14,11 @@ 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ -from accounts.views import SafePasswordResetView, SafeRegisterView +from accounts.views import ( + SafePasswordResetView, + SafeRegisterView, + safe_obtain_expiring_auth_token, +) from allauth.account.views import ConfirmEmailView from django.conf import settings from django.conf.urls import include, url @@ -26,7 +30,6 @@ SpectacularRedocView, SpectacularSwaggerView, ) -from rest_framework_expiring_authtoken.views import obtain_expiring_auth_token from web import views handler404 = "web.views.page_not_found" @@ -43,7 +46,7 @@ url(r"^api/admin/", admin.site.urls), url( r"^api/auth/login", - obtain_expiring_auth_token, + safe_obtain_expiring_auth_token, name="obtain_expiring_auth_token", ), url( diff --git a/frontend/src/views/web/auth/login.html b/frontend/src/views/web/auth/login.html index c800bbeee9..ef622bfdcf 100644 --- a/frontend/src/views/web/auth/login.html +++ b/frontend/src/views/web/auth/login.html @@ -20,7 +20,7 @@
Passwords do not match
Password is less than 8 characters.
+Password must be at most 128 characters.
Password confirmation is required.