1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

fix(accounts): a username longer than the field is refused by the login form

Django's login form gives the username its max_length after the field
is built, so a longer username was not refused and reached
authentication. django-axes then stored it in its 255-character column,
which PostgreSQL refuses: a login with a 300-character username was a
500 (SQLite does not enforce the length). Both login forms, with and
without OTP, now check the length, so such a username is a form error
and never reaches authentication, the lockout or the Logs page.

This also turns the PostgreSQL leg of CI green again: the S-16 test
from #167 hit exactly this.
This commit is contained in:
catborise 2026-10-23 15:48:28 +03:00
parent 6c080aa4a3
commit 3210ef2eee
5 changed files with 36 additions and 5 deletions

View file

@ -1,5 +1,7 @@
from appsettings.settings import app_settings
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import AuthenticationForm
from django.core.validators import MaxLengthValidator
from django.forms import EmailField, Form, ModelForm, ValidationError
from django.utils.translation import gettext_lazy as _
@ -74,3 +76,18 @@ class UserSSHKeyForm(ModelForm):
class EmailOTPForm(Form):
email = EmailField(label=_("Email"))
class BoundedUsernameMixin:
"""Django's login form gives the username its max_length after the field
is built, so a longer one is not refused and reaches authentication;
django-axes then cannot store it (PostgreSQL refuses it: 500)."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
username = self.fields["username"]
username.validators.append(MaxLengthValidator(username.max_length))
class LoginForm(BoundedUsernameMixin, AuthenticationForm):
pass

View file

@ -15,8 +15,10 @@ from django.core.exceptions import ValidationError
from django_otp.forms import OTPAuthenticationForm
from django_otp.plugins.otp_totp.models import TOTPDevice
from accounts.forms import BoundedUsernameMixin
class SingleDeviceOTPForm(OTPAuthenticationForm):
class SingleDeviceOTPForm(BoundedUsernameMixin, OTPAuthenticationForm):
def _confirmed_totp(self, user, for_verify=False):
"""The user's oldest confirmed TOTP device, or None. With for_verify
the row is locked (select_for_update) so the token's replay and

View file

@ -167,6 +167,12 @@ class OTPLoginHTTPTestCase(TestCase):
self._post(otp_token=token_for(device))
self.assertEqual(self.messages(), ["Login"])
def test_an_oversized_username_is_refused_by_the_form(self):
response = self._post(username="x" * 300, otp_token="")
self.assertEqual(response.status_code, 200)
self.assertIn("username", response.context["form"].errors)
self.assertEqual(self.messages(), [])
def test_a_signed_in_user_is_sent_on_from_the_login_page(self):
from django.urls import reverse
self.client.force_login(self.user)

View file

@ -3,6 +3,7 @@ from django.contrib.auth.views import LoginView, LogoutView
from django.urls import path
from . import views
from .forms import LoginForm
from .otp import SingleDeviceOTPForm
app_name = "accounts"
@ -51,5 +52,6 @@ if settings.OTP_ENABLED:
]
else:
urlpatterns += (
path("login/", LoginView.as_view(template_name="login.html", redirect_authenticated_user=True), name="login"),
path("login/", LoginView.as_view(template_name="login.html", authentication_form=LoginForm,
redirect_authenticated_user=True), name="login"),
)

View file

@ -58,9 +58,13 @@ class LoginRowInputTests(TestCase):
def login(self, username):
return self.client.post(reverse("accounts:login"), {"username": username, "password": "wrong"})
def test_an_oversized_username_is_cut_to_the_field(self):
self.assertEqual(self.login("x" * 300).status_code, 200)
self.assertEqual(rows(), [("x" * 150, "-", "Login failed", False, "127.0.0.1")])
def test_an_oversized_username_is_refused_by_the_form(self):
# longer than the username field: no login attempt, so nothing for
# django-axes to store (its 255-character column refused it on PostgreSQL)
response = self.login("x" * 300)
self.assertEqual(response.status_code, 200)
self.assertIn("username", response.context["form"].errors)
self.assertFalse(Logs.objects.exists())
def test_a_crlf_username_stays_in_its_row(self):
forged = "bob\r\nadmin - Login"