diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 08d8da08..f5bc8d3a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ exclude: 'docs|migrations|.git|.tox' -default_stages: [commit] +default_stages: [pre-commit] fail_fast: true repos: diff --git a/config/settings/local.py b/config/settings/local.py index 6025087f..c6618ab3 100644 --- a/config/settings/local.py +++ b/config/settings/local.py @@ -1,6 +1,6 @@ """Use these settings for local development.""" -from django.core.management.utils import get_random_secret_key +from django.utils.crypto import get_random_string from .base import * # noqa from .base import AUTH_METHOD, REST_FRAMEWORK, env @@ -12,7 +12,7 @@ # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key SECRET_KEY = env( "DJANGO_SECRET_KEY", - default=get_random_secret_key(), + default=get_random_string(50, allowed_chars="abcdefghijklmnopqrstuvwxyz0123456789"), ) # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts ALLOWED_HOSTS = ["*"] diff --git a/scram/route_manager/views.py b/scram/route_manager/views.py index 50abbd7f..d6e341db 100644 --- a/scram/route_manager/views.py +++ b/scram/route_manager/views.py @@ -24,6 +24,7 @@ from scram.route_manager.models import WebSocketSequenceElement from ..route_manager.api.views import EntryViewSet +from ..shared.shared_code import make_random_password from ..users.models import User from .models import ActionType, Entry @@ -50,10 +51,7 @@ def home_page(request, prefilter=None): if settings.AUTOCREATE_ADMIN: if User.objects.count() == 0: - password = User.objects.make_random_password( - length=20, - allowed_chars="abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*", - ) + password = make_random_password(length=20) User.objects.create_superuser("admin", "admin@example.com", password) authenticated_admin = authenticate(request, username="admin", password=password) login(request, authenticated_admin) diff --git a/scram/shared/__init__.py b/scram/shared/__init__.py new file mode 100644 index 00000000..9af65b5d --- /dev/null +++ b/scram/shared/__init__.py @@ -0,0 +1 @@ +"""A Module for defining shared code and constants.""" diff --git a/scram/shared/shared_code.py b/scram/shared/shared_code.py new file mode 100644 index 00000000..926d7bb7 --- /dev/null +++ b/scram/shared/shared_code.py @@ -0,0 +1,59 @@ +"""A Module for defining shared code.""" + +import secrets +import string + + +def make_random_password(length: int = 20, min_digits: int = 5, max_attempts: int = 10000) -> str: + """make_random_password replaces the deprecated django make_random_password function. + + Generates a random password of a specified length containing at least a specified number of digits using the + official python best practices and some additional sanity checks. The python docs for this can be found at + https://docs.python.org/3/library/secrets.html#recipes-and-best-practices. Note that generating long passwords + with a high number of digits (>100) is inefficient and should be avoided. This password should only be used for + temporary purposes, such as for a user to log in to the web interface and change their password. + + Args: + length (int, optional): The total length of the password to generate. Defaults to 20. + min_digits (int, optional): The minimum number of digits the password needs. Defaults to 5. + max_attempts (int, optional): The maximum number of attempts to generate a valid password. Defaults to 10000. + + Raises: + ValueError: Password length must be at least 1 + ValueError: min_digits cannot be negative + ValueError: min_digits cannot exceed password length + ValueError: For performance reasons, min_digits cannot exceed 30% of the password length + RuntimeError: Failed to generate a valid password after max_attempts attempts + + Returns: + password (str): The generated password. + """ + if length < 1: + message = "Password length must be at least 1" + raise ValueError(message) + if min_digits < 0: + message = "min_digits cannot be negative" + raise ValueError(message) + if min_digits > length: + message = "min_digits cannot exceed password length" + raise ValueError(message) + # Only allow a somewhat arbitrary threshold of 30% of the password length for min_digits, for performance reasons. + if min_digits > length * 0.3: + message = "For performance reasons, min_digits cannot exceed 30% of the password length" + raise ValueError(message) + + alphabet = string.ascii_letters + string.digits + + for _attempt in range(max_attempts): + password = "".join(secrets.choice(alphabet) for _i in range(length)) + if ( + any(c.islower() for c in password) + and any(c.isupper() for c in password) + and sum(c.isdigit() for c in password) >= min_digits + ): + return password + + # If we reached this point, we failed to generate a valid password after max_attempts attempts, likely due to the + # required password length being very long and the min_digits being high. + message = f"Failed to generate a valid password after {max_attempts} attempts" + raise RuntimeError(message)