project-callisto/callisto-core

View on GitHub
callisto_core/accounts/forms.py

Summary

Maintainability
A
0 mins
Test Coverage
import logging
from collections import OrderedDict
from hashlib import sha256

import bcrypt

from django import forms
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import (
    AuthenticationForm,
    PasswordChangeForm,
    PasswordResetForm,
    SetPasswordForm,
    UserCreationForm,
)
from django.core.exceptions import ValidationError
from django.forms.fields import CharField
from django.forms.widgets import PasswordInput, TextInput
from django.utils.safestring import mark_safe

from callisto_core.utils.api import NotificationApi, TenantApi

from . import auth, models, validators

User = get_user_model()
logger = logging.getLogger(__name__)

TERMS_NOT_ACCEPTED_ERROR = (
    "You have to accept the terms of service to register with Callisto."
)
REQUIRED_ERROR = "The {0} field is required."


class LoginForm(AuthenticationForm):
    username = CharField(
        max_length=30,
        label="Username",
        error_messages={"required": REQUIRED_ERROR.format("username")},
    )
    password = CharField(
        max_length=64,
        label="Password",
        widget=PasswordInput(attrs={"autocomplete": "off"}),
        error_messages={"required": REQUIRED_ERROR.format("password")},
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if TenantApi.site_settings("DISABLE_SIGNUP", cast=bool, request=self.request):
            label = "Email Address"
        else:
            label = "Username"
        self.fields["username"] = CharField(
            max_length=64,
            label=label,
            error_messages={"required": REQUIRED_ERROR.format(label)},
        )

    def confirm_login_allowed(self, user):
        super().confirm_login_allowed(user)
        # current_site_id = self.request.site.id
        # if user.account.site_id is not current_site_id:
        #     error_msg = "user site_id not matching in login request"
        #     # XXX (lojikil//Stefan Trail of Bits) removing some indirection
        #     # here and just making this an error; a normal user will probably
        #     # rarely have mismatching site_ids, but it's a common pattern for
        #     # attackers, so we should just accept the warning here (and alert
        #     # on it in something like an SIEM)
        #     logger.error(error_msg)
        #     raise ValidationError(
        #         self.error_messages["invalid_login"],
        #         code="invalid_login",
        #         params={"username": self.username_field.verbose_name},
        #     )


class SignUpForm(UserCreationForm):

    username = CharField(
        label="Username",
        widget=TextInput(
            attrs={"class": "show-requirements", "data-requirement": "required"}
        ),
        error_messages={
            "required": REQUIRED_ERROR.format("username"),
            "unique": "Username invalid. Please try another.",
        },
    )
    password1 = CharField(
        min_length=settings.PASSWORD_MIN_LENGTH,
        max_length=settings.PASSWORD_MAX_LENGTH,
        label="Password",
        widget=PasswordInput(
            attrs={"class": "show-requirements", "data-requirement": "password", "autocomplete": "off"}
        ),
        error_messages={"required": REQUIRED_ERROR.format("password")},
    )
    password2 = CharField(
        widget=PasswordInput(
            attrs={
                "class": "show-requirements",
                "data-requirement": "password-confirmation",
                "autocomplete": "off",
            }
        ),
        label="Confirm password",
        error_messages={"required": REQUIRED_ERROR.format("password confirmation")},
    )
    email = forms.EmailField(
        required=False,
        label="Optional email",
        help_text=mark_safe(
            """
            Your email is only used to reset your password if you lose it.
        """
        ),
    )
    terms = forms.BooleanField(
        required=True,
        label=mark_safe(
            """
            I have read and agree to Callisto\'s Terms and Privacy Policy
        """
        ),
        help_text=mark_safe(
            """
            We care deeply about your privacy, and know you do too.
            Your information will remain completely private until you choose otherwise.
            Read more in Callisto's
            <a href="/about/our-policies/#terms-of-service" target="_blank">
            Terms</a> and
            <a href="/about/our-policies/#privacy-policy" target="_blank">
            Privacy Policy</a>.
        """
        ),
        error_messages={"required": TERMS_NOT_ACCEPTED_ERROR},
    )

    class Meta:
        model = User
        fields = ("username", "password1", "password2", "email")


class FormattedPasswordResetForm(PasswordResetForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["email"].label = "Enter your email to reset your password"

    def save(self, *args, **kwargs):
        kwargs["domain_override"] = TenantApi.get_current_domain()
        super().save(*args, **kwargs)

    def get_users(self, email):
        """Get users that would match the email passed in.

        Updated to support the encrypted login format created during 2019
        Summer Maintenance.
        """
        email = sha256(email.encode("utf-8")).hexdigest()
        email_index = auth.index(email)

        active_users = models.Account.objects.filter(**{"email_index": email_index})

        return (
            User.objects.get(pk=u.user_id)
            for u in active_users
            if bcrypt.checkpw(email.encode("utf-8"), u.encrypted_email.encode("utf-8"))
        )

    def send_mail(self, *args, **kwargs):
        NotificationApi.send_password_reset_email(self, *args, **kwargs)


class CustomSetPasswordForm(SetPasswordForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["new_password1"] = CharField(
            max_length=settings.PASSWORD_MAX_LENGTH,
            min_length=settings.PASSWORD_MIN_LENGTH,
            label=self.password1_label,
            widget=PasswordInput(),
            error_messages={"required": REQUIRED_ERROR.format("password")},
        )


class FormattedSetPasswordForm(CustomSetPasswordForm):
    password1_label = "Enter your new password"


class ActivateSetPasswordForm(CustomSetPasswordForm):
    password1_label = "Password"


class FormattedPasswordChangeForm(PasswordChangeForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["new_password1"] = CharField(
            min_length=settings.PASSWORD_MIN_LENGTH,
            max_length=settings.PASSWORD_MAX_LENGTH,
            label="Enter your new password",
            widget=PasswordInput(attrs={"autocomplete": "off"}),
            error_messages={"required": REQUIRED_ERROR.format("password")},
        )
        self.fields["new_password2"].label = "Confirm new password"
        self.fields["new_password2"].widget.attrs["autocomplete"] = "off"
        self.fields["old_password"].label = "Old password"
        self.fields["old_password"].widget.attrs["autocomplete"] = "off"


# in original PasswordChangeForm file to reorder fields
FormattedPasswordChangeForm.base_fields = OrderedDict(
    (k, PasswordChangeForm.base_fields[k])
    for k in ["old_password", "new_password1", "new_password2"]
)


class ReportingVerificationEmailForm(PasswordResetForm):
    def __init__(self, *args, school_email_domain, **kwargs):
        self.school_email_domain = school_email_domain
        if kwargs.get("instance"):  # TODO: remove
            kwargs.pop("instance")
        if kwargs.get("view"):  # TODO: remove
            kwargs.pop("view")
        super().__init__(*args, **kwargs)
        email_field = self.fields["email"]
        email_field.label = "Your school email"
        email_field.label_suffix = "*"
        email_field.widget.attrs.update(**self.create_placeholder())

    def create_placeholder(self):
        return {
            "placeholder": ", ".join(
                ["myname@" + x for x in self.school_email_domain.split(",")]
            )
        }

    def clean_email(self):
        validators.validate_school_email(
            self.data.get("email"), self.school_email_domain
        )
        return self.data.get("email")