This commit is contained in:
Gnonpi 2025-11-17 13:45:10 +01:00 committed by GitHub
commit b0bae9a48e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 171 additions and 7 deletions

View file

@ -1083,6 +1083,7 @@ answer newbie questions, and generally made Django that much better:
Vinny Do <vdo.code@gmail.com>
Vitaly Babiy <vbabiy86@gmail.com>
Vitaliy Yelnik <velnik@gmail.com>
Viviès Denis <legnonpi@gmail.com>
Vladimir Kuzma <vladimirkuzma.ch@gmail.com>
Vlado <vlado@labath.org>
Vsevolod Solovyov

View file

@ -1,4 +1,5 @@
import getpass
import sys
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
@ -20,6 +21,13 @@ class Command(BaseCommand):
raise CommandError("aborted")
return p
def _get_stdin(self):
try:
stdin_content = sys.stdin.readline()
return stdin_content, stdin_content
except Exception:
raise CommandError("aborted")
def add_arguments(self, parser):
parser.add_argument(
"username",
@ -35,13 +43,48 @@ class Command(BaseCommand):
choices=tuple(connections),
help='Specifies the database to use. Default is "default".',
)
parser.add_argument(
"--stdin",
action="store_true",
help="Read new password from stdin rather than prompting. Default is False",
)
parser.add_argument(
"--noinput",
"--no-input",
action="store_false",
dest="interactive",
help=(
"Tells Django to NOT prompt the user for input of any kind. "
"You must use --stdin with --noinput."
),
)
def handle(self, *args, **options):
def _input_getter_getpass():
p1 = self._get_pass()
p2 = self._get_pass("Password (again): ")
return p1, p2
def _input_getter_stdin():
return self._get_stdin()
if options["username"]:
username = options["username"]
else:
username = getpass.getuser()
if not options["interactive"] ^ options["stdin"]:
raise CommandError(
"The '--no-input' option must be used " "with the '--stdin' option."
)
if options["stdin"]:
input_getter = _input_getter_stdin
max_tries = 1
else:
input_getter = _input_getter_getpass
max_tries = 3
try:
u = UserModel._default_manager.using(options["database"]).get(
**{UserModel.USERNAME_FIELD: username}
@ -51,13 +94,11 @@ class Command(BaseCommand):
self.stdout.write("Changing password for user '%s'" % u)
MAX_TRIES = 3
count = 0
p1, p2 = 1, 2 # To make them initially mismatch.
password_validated = False
while (p1 != p2 or not password_validated) and count < MAX_TRIES:
p1 = self._get_pass()
p2 = self._get_pass("Password (again): ")
while (p1 != p2 or not password_validated) and count < max_tries:
p1, p2 = input_getter()
if p1 != p2:
self.stdout.write("Passwords do not match. Please try again.")
count += 1
@ -71,9 +112,10 @@ class Command(BaseCommand):
else:
password_validated = True
if count == MAX_TRIES:
if count == max_tries:
raise CommandError(
"Aborting password change for user '%s' after %s attempts" % (u, count)
"Aborting password change for user '%s' after %s attempt%s"
% (u, count, "s" if count >= 2 else "")
)
u.set_password(p1)

View file

@ -106,6 +106,9 @@ Minor features
* The default iteration count for the PBKDF2 password hasher is increased from
1,200,000 to 1,500,000.
* The new :option:`changepassword --stdin` option accept a new password value
from stdin.
:mod:`django.contrib.contenttypes`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View file

@ -94,7 +94,13 @@ of changing a user's password from the command line. It prompts you to
change the password of a given user which you must enter twice. If
they both match, the new password will be changed immediately. If you
do not supply a user, the command will attempt to change the password
whose username matches the current system user.
whose username matches the current system user. You can also use the
:option:`--stdin` to pass the new password via a pipe:
.. console::
$ echo "new_password" | python manage.py changepassword admin --stdin
You can also change a password programmatically, using
:meth:`~django.contrib.auth.models.User.set_password`:

View file

@ -261,6 +261,118 @@ class ChangepasswordManagementCommandTestCase(TestCase):
User.objects.create_user(username="J\xfalia", password="qwerty")
call_command("changepassword", username="J\xfalia", stdout=self.stdout)
@mock.patch(
"django.contrib.auth.management.commands.changepassword.sys.stdin.readline",
return_value="not qwerty",
)
def test_that_stdin_pipe_is_allowed(self, _):
"""
Executing the changepassword command with the --stdin option
should change joe's password.
"""
joe = User.objects.get(username="joe")
self.assertFalse(joe.check_password("not qwerty"))
call_command(
"changepassword",
username="joe",
stdout=self.stdout,
stdin=True,
interactive=False,
)
command_output = self.stdout.getvalue().strip()
self.assertEqual(
command_output,
"Changing password for user 'joe'\n"
"Password changed successfully for user 'joe'",
)
joe.refresh_from_db()
self.assertTrue(joe.check_password("not qwerty"))
@mock.patch(
"django.contrib.auth.management.commands.changepassword.sys.stdin.readline",
return_value="1234",
)
def test_that_stdin_pipe_validates_only_once(self, _):
"""
A CommandError should be raised if the password value read with --stdin
fail validation, with only one error message.
"""
joe = User.objects.get(username="joe")
self.assertTrue(joe.check_password("qwerty"))
abort_msg = "Aborting password change for user 'joe' after 1 attempt"
with self.assertRaisesMessage(CommandError, abort_msg):
call_command(
"changepassword",
username="joe",
stdout=self.stdout,
stderr=self.stderr,
stdin=True,
interactive=False,
)
self.assertEqual(
self.stdout.getvalue().strip(),
"Changing password for user 'joe'",
)
self.assertIn(
"This password is entirely numeric.",
self.stderr.getvalue(),
)
joe.refresh_from_db()
self.assertTrue(joe.check_password("qwerty"))
@mock.patch(
"django.contrib.auth.management.commands.changepassword.sys.stdin.readline",
side_effect=RuntimeError("stop"),
)
def test_that_stdin_pipe_can_fail_gracefully(self, _):
"""
Executing the changepassword command with the --stdin option
with the call to stdin failing,
should raise a CommandError and leave the password unchanged.
"""
joe = User.objects.get(username="joe")
self.assertFalse(joe.check_password("not qwerty"))
msg = "aborted"
with self.assertRaisesMessage(CommandError, msg):
call_command(
"changepassword",
username="joe",
stdout=self.stdout,
stderr=self.stderr,
stdin=True,
interactive=False,
)
joe.refresh_from_db()
self.assertFalse(joe.check_password("not qwerty"))
def test_that_stdin_and_noinput_options_must_be_used_together(self):
with self.assertRaisesMessage(CommandError, ""):
call_command(
"changepassword",
username="joe",
stdout=self.stdout,
stderr=self.stderr,
stdin=True,
interactive=True,
)
with self.assertRaisesMessage(CommandError, ""):
call_command(
"changepassword",
username="joe",
stdout=self.stdout,
stderr=self.stderr,
stdin=False,
interactive=False,
)
class MultiDBChangepasswordManagementCommandTestCase(TestCase):
databases = {"default", "other"}