users: Require admin credentials when creating or editing a user

This change prevents the plinth user to become a superuser without
knowing an admin password.

Users module and action script:
- User credentials are now required for the subcommands: create-user,
  set-user-password, add-user-to-group (if the group is admin),
  remove-user-from-group (if the group is admin), set-user-status,
  remove-user (if the removed user is the last admin user.
  Note: the web UI doesn't allow to delete last admin user).
- subcommand remove-users requires authentication if the user is last
  admin user. Password must be provided through standard input.
- subcommand remove-group: do not allow to remove group 'admin'
- User credentials must be provided using the argument
  --auth-user and a passsword must be provided through standard input.
- If there are no users in the admin group, no admin password is
  required and if the --auth-user argument is required, it can be an
  empty string.

Users web UI:
- An admin needs to enter current password to create and edit a user
  and to change user's password.
- Show more detailed error text on exceptions when submitting forms.
- Show page title on the edit and create user pages.

Users unit and functional tests:
- Added a configuration parameters to the pytest configuration file
  to set current admin user/password.
- Added a configuration parameter 'ssh_port' to the functional tests.
  You can overwrite this with the FREEDOMBOX_SSH_PORT environment
  variable. Modified HACKING.md accordingly.
- Added an unit test:
     - test changing the password as a non-admin user.
     - test invalid admin password input.
     - test that removing the admin group fails.
- Capture stdout and stderr in the unit tests when calling an action
  script to be able to see more info on exceptions.
- Added functional tests for setting ssh keys and changing passwords
  for admin and non-admin users.
- Added a functional test for setting a user as active/inactive.

Changes during review [sunil]:
- Move uncommon functional step definitions to users module from global. This is
  keep the common functional step definitions to minimal level and promote when
  needed.
- Minor styling changes, flake8 fixes.
- Don't require pampy module when running non-admin tests. This allows tests to
  be run from outside the container on the host machine without python3-pam
  installed.
- Call the confirm password field 'Authorization Password'. This avoid confusion
  with a very common field 'Confirm Password' which essentially means retype
  your password to ensure you didn't get it wrong. Add label explaining why the
  field exists.
- Don't hard-code /tmp path in test_actions.py. Use tmp_path_factory fixture
  provided by pytest.
- Remove unused _get_password_hash() from actions/users.
- Undo splitting ldapgid output before parsing. It does not seem correct and
  could introduce problems when field values contain spaces.

Tests performed:
- No failed unit tests (run with and without sudo).
- All 'users' functional tests pass.
- Creating an admin user during the first boot wizard succeeds.
- Creating a user using the web UI with an empty or wrong admin
  password fails and with the correct admin password succeeds.
- Editing a user using the web UI with an empty or wrong admin
  password fails and with the correct admin password succeeds.
- Changing user's password using the web UI with an empty or wrong
  admin password fails and with the correct admin password succeeds.
- Above mentioned user action script commands can't be run without
  correct credentials.
- Adding the daemon user to the freedombox-share group succeeds when
  installing certain apps (deluge, mldonkey, syncthing, transmission).

Signed-off-by: Veiko Aasa <veiko17@disroot.org>
[sunil: Move uncommon functional step definitions to users module from global]
[sunil: Minor styling changes, flake8 fixes]
[sunil: Don't require pampy module when running non-admin tests]
[sunil: Call the confirm password field 'Authorization Password']
[sunil: Don't hard-code /tmp path in test_actions.py]
[sunil: Remove unused _get_password_hash() from actions/users]
[sunil: Undo splitting ldapgid output before parsing]
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Sunil Mohan Adapa <sunil@medhas.org>
This commit is contained in:
Veiko Aasa 2020-08-28 11:48:30 +03:00 committed by Sunil Mohan Adapa
parent 6b61ca2f18
commit dfaf009d3c
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
15 changed files with 796 additions and 127 deletions

View File

@ -349,7 +349,7 @@ tests will create the required user using FreedomBox's first boot process.
**When inside a container/VM you will need to target the guest** **When inside a container/VM you will need to target the guest**
```bash ```bash
guest$ export FREEDOMBOX_URL=https://localhost FREEDOMBOX_SAMBA_PORT=445 guest$ export FREEDOMBOX_URL=https://localhost FREEDOMBOX_SSH_PORT=22 FREEDOMBOX_SAMBA_PORT=445
``` ```
You will be running `py.test-3`. You will be running `py.test-3`.

View File

@ -14,8 +14,9 @@ import sys
import augeas import augeas
from plinth import action_utils from plinth import action_utils, utils
INPUT_LINES = None
ACCESS_CONF = '/etc/security/access.conf' ACCESS_CONF = '/etc/security/access.conf'
LDAPSCRIPTS_CONF = '/etc/ldapscripts/freedombox-ldapscripts.conf' LDAPSCRIPTS_CONF = '/etc/ldapscripts/freedombox-ldapscripts.conf'
@ -31,10 +32,13 @@ def parse_arguments():
subparser = subparsers.add_parser('create-user', subparser = subparsers.add_parser('create-user',
help='Create an LDAP user') help='Create an LDAP user')
subparser.add_argument('username', help='Name of the LDAP user to create') subparser.add_argument('username', help='Name of the LDAP user to create')
subparser.add_argument('--auth-user', required=True)
subparser = subparsers.add_parser('remove-user', subparser = subparsers.add_parser('remove-user',
help='Delete an LDAP user') help='Delete an LDAP user')
subparser.add_argument('username', help='Name of the LDAP user to delete') subparser.add_argument(
'username', help='Name of the LDAP user to delete. If the username is '
'the last admin user, a password should be provided through STDIN.')
subparser = subparsers.add_parser('rename-user', subparser = subparsers.add_parser('rename-user',
help='Rename an LDAP user') help='Rename an LDAP user')
@ -45,6 +49,7 @@ def parse_arguments():
help='Set the password of an LDAP user') help='Set the password of an LDAP user')
subparser.add_argument( subparser.add_argument(
'username', help='Name of the LDAP user to set the password for') 'username', help='Name of the LDAP user to set the password for')
subparser.add_argument('--auth-user', required=True)
subparser = subparsers.add_parser('create-group', subparser = subparsers.add_parser('create-group',
help='Create an LDAP group') help='Create an LDAP group')
@ -65,12 +70,14 @@ def parse_arguments():
help='Add an LDAP user to an LDAP group') help='Add an LDAP user to an LDAP group')
subparser.add_argument('username', help='LDAP user to add to group') subparser.add_argument('username', help='LDAP user to add to group')
subparser.add_argument('groupname', help='LDAP group to add the user to') subparser.add_argument('groupname', help='LDAP group to add the user to')
subparser.add_argument('--auth-user', required=False)
subparser = subparsers.add_parser('set-user-status', subparser = subparsers.add_parser('set-user-status',
help='Set user as active or inactive') help='Set user as active or inactive')
subparser.add_argument('username', help='User to change status') subparser.add_argument('username', help='User to change status')
subparser.add_argument('status', choices=['active', 'inactive'], subparser.add_argument('status', choices=['active', 'inactive'],
help='New status of the user') help='New status of the user')
subparser.add_argument('--auth-user', required=True)
subparser = subparsers.add_parser( subparser = subparsers.add_parser(
'remove-user-from-group', 'remove-user-from-group',
@ -78,6 +85,7 @@ def parse_arguments():
subparser.add_argument('username', help='LDAP user to remove from group') subparser.add_argument('username', help='LDAP user to remove from group')
subparser.add_argument('groupname', subparser.add_argument('groupname',
help='LDAP group to remove the user from') help='LDAP group to remove the user from')
subparser.add_argument('--auth-user', required=False)
help_get_group_users = 'Get the list of all users in an LDAP group' help_get_group_users = 'Get the list of all users in an LDAP group'
subparser = subparsers.add_parser('get-group-users', subparser = subparsers.add_parser('get-group-users',
@ -90,6 +98,38 @@ def parse_arguments():
return parser.parse_args() return parser.parse_args()
def validate_user(username, must_be_admin=True):
"""Validate a user."""
if must_be_admin:
admins = get_admin_users()
if not admins:
# any user is valid
return
if not username:
msg = 'Argument --auth-user is required'
raise argparse.ArgumentTypeError(msg)
if username not in admins:
msg = '"{}" is not authorized to perform this action'.format(
username)
raise argparse.ArgumentTypeError(msg)
if not username:
msg = 'Argument --auth-user is required'
raise argparse.ArgumentTypeError(msg)
validate_password(username)
def validate_password(username):
"""Raise an error if the user password is invalid."""
password = read_password(last=True)
if not utils.is_authenticated_user(username, password):
raise argparse.ArgumentTypeError("Invalid credentials")
def subcommand_first_setup(_): def subcommand_first_setup(_):
"""Perform initial setup of LDAP.""" """Perform initial setup of LDAP."""
# Avoid reconfiguration of slapd during module upgrades, because # Avoid reconfiguration of slapd during module upgrades, because
@ -243,18 +283,36 @@ def disconnect_samba_user(username):
raise raise
def read_password(): def get_input_lines():
"""Read the password from stdin.""" """Return list of input lines from stdin."""
return ''.join(sys.stdin) global INPUT_LINES
if INPUT_LINES is None:
INPUT_LINES = [line.strip() for line in sys.stdin]
return INPUT_LINES
def read_password(last=False):
"""Return the password.
Set last=True to read password from last line of the input.
"""
line = -1 if last else 0
return get_input_lines()[line]
def subcommand_create_user(arguments): def subcommand_create_user(arguments):
"""Create an LDAP user, set password and flush cache.""" """Create an LDAP user, set password and flush cache."""
_run(['ldapadduser', arguments.username, 'users']) username = arguments.username
auth_user = arguments.auth_user
validate_user(auth_user)
_run(['ldapadduser', username, 'users'])
password = read_password() password = read_password()
set_user_password(arguments.username, password) set_user_password(username, password)
flush_cache() flush_cache()
set_samba_user(arguments.username, password) set_samba_user(username, password)
def subcommand_remove_user(arguments): def subcommand_remove_user(arguments):
@ -262,6 +320,10 @@ def subcommand_remove_user(arguments):
username = arguments.username username = arguments.username
groups = get_user_groups(username) groups = get_user_groups(username)
# require authentication if the user is last admin user
if get_group_users('admin') == [username]:
validate_password(username)
delete_samba_user(username) delete_samba_user(username)
for group in groups: for group in groups:
@ -314,9 +376,58 @@ def set_samba_user(username, password):
def subcommand_set_user_password(arguments): def subcommand_set_user_password(arguments):
"""Set a user's password.""" """Set a user's password."""
username = arguments.username
auth_user = arguments.auth_user
must_be_admin = username != auth_user
validate_user(auth_user, must_be_admin=must_be_admin)
password = read_password() password = read_password()
set_user_password(arguments.username, password) set_user_password(username, password)
set_samba_user(arguments.username, password) set_samba_user(username, password)
def get_admin_users():
"""Returns list of members in the admin group.
Raises an error if the slapd service is not running.
"""
admin_users = []
try:
output = subprocess.check_output([
'ldapsearch', '-LLL', '-Q', '-Y', 'EXTERNAL', '-H', 'ldapi:///',
'-o', 'ldif-wrap=no', '-s', 'base', '-b',
'cn=admin,ou=groups,dc=thisbox', 'memberUid'
]).decode()
except subprocess.CalledProcessError as error:
if error.returncode == 32:
# no entries found
return []
raise
for line in output.splitlines():
if line.startswith('memberUid: '):
user = line.split('memberUid: ', 1)[1].strip()
admin_users.append(user)
return admin_users
def get_group_users(groupname):
"""Returns list of members in the group."""
try:
process = _run(['ldapgid', '-P', groupname], stdout=subprocess.PIPE)
except subprocess.CalledProcessError:
return [] # Group does not exist
output = process.stdout.decode()
# extract users from output, example: 'admin:*:10001:user1,user2'
users = output.rsplit(':')[-1].strip().split(',')
if users == ['']:
return []
return users
def get_user_groups(username): def get_user_groups(username):
@ -371,10 +482,14 @@ def subcommand_create_group(arguments):
def subcommand_remove_group(arguments): def subcommand_remove_group(arguments):
"""Remove an LDAP group.""" """Remove an LDAP group."""
if group_exists(arguments.groupname): groupname = arguments.groupname
_run(['ldapdeletegroup', arguments.groupname])
flush_cache() if groupname == 'admin':
raise argparse.ArgumentTypeError("Can't remove the group 'admin'")
if group_exists(groupname):
_run(['ldapdeletegroup', groupname])
flush_cache()
def add_user_to_group(username, groupname): def add_user_to_group(username, groupname):
@ -385,7 +500,12 @@ def add_user_to_group(username, groupname):
def subcommand_add_user_to_group(arguments): def subcommand_add_user_to_group(arguments):
"""Add an LDAP user to an LDAP group.""" """Add an LDAP user to an LDAP group."""
add_user_to_group(arguments.username, arguments.groupname) groupname = arguments.groupname
if groupname == 'admin':
validate_user(arguments.auth_user)
add_user_to_group(arguments.username, groupname)
flush_cache() flush_cache()
@ -396,31 +516,31 @@ def remove_user_from_group(username, groupname):
def subcommand_remove_user_from_group(arguments): def subcommand_remove_user_from_group(arguments):
"""Remove an LDAP user from an LDAP group.""" """Remove an LDAP user from an LDAP group."""
remove_user_from_group(arguments.username, arguments.groupname) username = arguments.username
groupname = arguments.groupname
if groupname == 'admin':
validate_user(arguments.auth_user)
remove_user_from_group(username, groupname)
flush_cache() flush_cache()
if arguments.groupname == 'freedombox-share': if groupname == 'freedombox-share':
disconnect_samba_user(arguments.username) disconnect_samba_user(username)
def subcommand_get_group_users(arguments): def subcommand_get_group_users(arguments):
"""Get the list of users of an LDAP group.""" """Get the list of users of an LDAP group."""
try: for user in get_group_users(arguments.groupname):
process = _run(['ldapgid', '-P', arguments.groupname], print(user)
stdout=subprocess.PIPE)
except subprocess.CalledProcessError:
return # Group does not exist, return empty list
output = process.stdout.decode()
users = output.rsplit(':')[-1]
if users:
for user in users.strip().split(','):
print(user)
def subcommand_set_user_status(arguments): def subcommand_set_user_status(arguments):
"""Set the status of the user.""" """Set the status of the user."""
username = arguments.username username = arguments.username
status = arguments.status status = arguments.status
auth_user = arguments.auth_user
validate_user(auth_user)
if status == 'active': if status == 'active':
flag = '-e' flag = '-e'

View File

@ -6,6 +6,7 @@ import re
from django import forms from django import forms
from django.contrib import auth, messages from django.contrib import auth, messages
from django.contrib.auth.forms import SetPasswordForm, UserCreationForm from django.contrib.auth.forms import SetPasswordForm, UserCreationForm
from django.contrib.auth.hashers import check_password
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
from django.core import validators from django.core import validators
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -71,9 +72,27 @@ USERNAME_FIELD = forms.CharField(
'letters, digits and @/./-/_ only.')) 'letters, digits and @/./-/_ only.'))
class PasswordConfirmForm(forms.Form):
"""Password confirmation form."""
confirm_password = forms.CharField(
widget=forms.PasswordInput,
label=ugettext_lazy('Authorization Password'), help_text=ugettext_lazy(
'Enter your current password to authorize account modifications.'))
def clean_confirm_password(self):
"""Check that current user's password matches."""
confirm_password = self.cleaned_data['confirm_password']
password_matches = check_password(confirm_password,
self.request.user.password)
if not password_matches:
raise ValidationError(_('Invalid password.'), code='invalid')
return confirm_password
class CreateUserForm(ValidNewUsernameCheckMixin, class CreateUserForm(ValidNewUsernameCheckMixin,
plinth.forms.LanguageSelectionFormMixin, plinth.forms.LanguageSelectionFormMixin,
UserCreationForm): PasswordConfirmForm, UserCreationForm):
"""Custom user create form. """Custom user create form.
Include options to add user to groups. Include options to add user to groups.
@ -95,7 +114,8 @@ class CreateUserForm(ValidNewUsernameCheckMixin,
class Meta(UserCreationForm.Meta): class Meta(UserCreationForm.Meta):
"""Metadata to control automatic form building.""" """Metadata to control automatic form building."""
fields = ('username', 'password1', 'password2', 'groups', 'language') fields = ('username', 'password1', 'password2', 'groups', 'language',
'confirm_password')
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):
"""Initialize the form with extra request argument.""" """Initialize the form with extra request argument."""
@ -104,7 +124,7 @@ class CreateUserForm(ValidNewUsernameCheckMixin,
self.fields['username'].widget.attrs.update({ self.fields['username'].widget.attrs.update({
'autofocus': 'autofocus', 'autofocus': 'autofocus',
'autocapitalize': 'none', 'autocapitalize': 'none',
'autocomplete': 'username' 'autocomplete': 'username',
}) })
def save(self, commit=True): def save(self, commit=True):
@ -114,26 +134,34 @@ class CreateUserForm(ValidNewUsernameCheckMixin,
if commit: if commit:
user.userprofile.language = self.cleaned_data['language'] user.userprofile.language = self.cleaned_data['language']
user.userprofile.save() user.userprofile.save()
auth_username = self.request.user.username
confirm_password = self.cleaned_data['confirm_password']
process_input = '{0}\n{1}'.format(self.cleaned_data['password1'],
confirm_password).encode()
try: try:
actions.superuser_run( actions.superuser_run('users', [
'users', 'create-user',
['create-user', user.get_username()], user.get_username(), '--auth-user', auth_username
input=self.cleaned_data['password1'].encode()) ], input=process_input)
except ActionError: except ActionError as error:
messages.error(self.request, _('Creating LDAP user failed.')) messages.error(
self.request,
_('Creating LDAP user failed: {error}'.format(
error=error)))
for group in self.cleaned_data['groups']: for group in self.cleaned_data['groups']:
try: try:
actions.superuser_run( actions.superuser_run('users', [
'users', 'add-user-to-group',
['add-user-to-group', user.get_username(), group, '--auth-user',
user.get_username(), group]) auth_username
except ActionError: ], input=confirm_password.encode())
except ActionError as error:
messages.error( messages.error(
self.request, self.request,
_('Failed to add new user to {group} group.').format( _('Failed to add new user to {group} group: {error}').
group=group)) format(group=group, error=error))
group_object, created = Group.objects.get_or_create(name=group) group_object, created = Group.objects.get_or_create(name=group)
group_object.user_set.add(user) group_object.user_set.add(user)
@ -141,7 +169,7 @@ class CreateUserForm(ValidNewUsernameCheckMixin,
return user return user
class UserUpdateForm(ValidNewUsernameCheckMixin, class UserUpdateForm(ValidNewUsernameCheckMixin, PasswordConfirmForm,
plinth.forms.LanguageSelectionFormMixin, forms.ModelForm): plinth.forms.LanguageSelectionFormMixin, forms.ModelForm):
"""When user info is changed, also updates LDAP user.""" """When user info is changed, also updates LDAP user."""
username = USERNAME_FIELD username = USERNAME_FIELD
@ -158,7 +186,8 @@ class UserUpdateForm(ValidNewUsernameCheckMixin,
class Meta: class Meta:
"""Metadata to control automatic form building.""" """Metadata to control automatic form building."""
fields = ('username', 'groups', 'ssh_keys', 'language', 'is_active') fields = ('username', 'groups', 'ssh_keys', 'language', 'is_active',
'confirm_password')
model = User model = User
widgets = { widgets = {
'groups': plinth.forms.CheckboxSelectMultipleWithReadOnly(), 'groups': plinth.forms.CheckboxSelectMultipleWithReadOnly(),
@ -210,9 +239,11 @@ class UserUpdateForm(ValidNewUsernameCheckMixin,
user = super(UserUpdateForm, self).save(commit=False) user = super(UserUpdateForm, self).save(commit=False)
# Profile is auto saved with user object # Profile is auto saved with user object
user.userprofile.language = self.cleaned_data['language'] user.userprofile.language = self.cleaned_data['language']
auth_username = self.request.user.username
confirm_password = self.cleaned_data['confirm_password']
# If user is updating their own profile then only translate the pages # If user is updating their own profile then only translate the pages
if self.username == self.request.user.username: if self.username == auth_username:
set_language(self.request, None, user.userprofile.language) set_language(self.request, None, user.userprofile.language)
if commit: if commit:
@ -240,8 +271,9 @@ class UserUpdateForm(ValidNewUsernameCheckMixin,
try: try:
actions.superuser_run('users', [ actions.superuser_run('users', [
'remove-user-from-group', 'remove-user-from-group',
user.get_username(), old_group user.get_username(), old_group, '--auth-user',
]) auth_username
], input=confirm_password.encode())
except ActionError: except ActionError:
messages.error(self.request, messages.error(self.request,
_('Failed to remove user from group.')) _('Failed to remove user from group.'))
@ -251,18 +283,23 @@ class UserUpdateForm(ValidNewUsernameCheckMixin,
try: try:
actions.superuser_run('users', [ actions.superuser_run('users', [
'add-user-to-group', 'add-user-to-group',
user.get_username(), new_group user.get_username(), new_group, '--auth-user',
]) auth_username
], input=confirm_password.encode())
except ActionError: except ActionError:
messages.error(self.request, messages.error(self.request,
_('Failed to add user to group.')) _('Failed to add user to group.'))
try: try:
actions.superuser_run('ssh', [ actions.superuser_run('ssh', [
'set-keys', '--username', 'set-keys',
user.get_username(), '--keys', '--username',
self.cleaned_data['ssh_keys'].strip() user.get_username(),
]) '--keys',
self.cleaned_data['ssh_keys'].strip(),
'--auth-user',
auth_username,
], input=confirm_password.encode())
except ActionError: except ActionError:
messages.error(self.request, _('Unable to set SSH keys.')) messages.error(self.request, _('Unable to set SSH keys.'))
@ -273,10 +310,13 @@ class UserUpdateForm(ValidNewUsernameCheckMixin,
else: else:
status = 'inactive' status = 'inactive'
try: try:
actions.superuser_run( actions.superuser_run('users', [
'users', 'set-user-status',
['set-user-status', user.get_username(),
user.get_username(), status]) status,
'--auth-user',
auth_username,
], input=confirm_password.encode())
except ActionError: except ActionError:
messages.error(self.request, messages.error(self.request,
_('Failed to change user status.')) _('Failed to change user status.'))
@ -297,7 +337,7 @@ class UserUpdateForm(ValidNewUsernameCheckMixin,
return cleaned_data return cleaned_data
class UserChangePasswordForm(SetPasswordForm): class UserChangePasswordForm(PasswordConfirmForm, SetPasswordForm):
"""Custom form that also updates password for LDAP users.""" """Custom form that also updates password for LDAP users."""
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):
@ -310,12 +350,16 @@ class UserChangePasswordForm(SetPasswordForm):
def save(self, commit=True): def save(self, commit=True):
"""Save the user model and change LDAP password as well.""" """Save the user model and change LDAP password as well."""
user = super(UserChangePasswordForm, self).save(commit) user = super(UserChangePasswordForm, self).save(commit)
auth_username = self.request.user.username
if commit: if commit:
process_input = '{0}\n{1}'.format(
self.cleaned_data['new_password1'],
self.cleaned_data['confirm_password']).encode()
try: try:
actions.superuser_run( actions.superuser_run('users', [
'users', ['set-user-password', 'set-user-password',
user.get_username()], user.get_username(), '--auth-user', auth_username
input=self.cleaned_data['new_password1'].encode()) ], input=process_input)
except ActionError: except ActionError:
messages.error(self.request, messages.error(self.request,
_('Changing LDAP user password failed.')) _('Changing LDAP user password failed.'))
@ -341,19 +385,25 @@ class FirstBootForm(ValidNewUsernameCheckMixin, auth.forms.UserCreationForm):
try: try:
actions.superuser_run( actions.superuser_run(
'users', 'users',
['create-user', user.get_username()], ['create-user',
user.get_username(), '--auth-user', ''],
input=self.cleaned_data['password1'].encode()) input=self.cleaned_data['password1'].encode())
except ActionError: except ActionError as error:
messages.error(self.request, _('Creating LDAP user failed.')) messages.error(
self.request,
_('Creating LDAP user failed: {error}'.format(
error=error)))
try: try:
actions.superuser_run( actions.superuser_run(
'users', 'users',
['add-user-to-group', ['add-user-to-group',
user.get_username(), 'admin']) user.get_username(), 'admin'])
except ActionError: except ActionError as error:
messages.error(self.request, messages.error(
_('Failed to add new user to admin group.')) self.request,
_('Failed to add new user to admin group: {error}'.format(
error=error)))
# Create initial Django groups # Create initial Django groups
for group_choice in UsersAndGroups.get_group_choices(): for group_choice in UsersAndGroups.get_group_choices():
@ -368,9 +418,11 @@ class FirstBootForm(ValidNewUsernameCheckMixin, auth.forms.UserCreationForm):
# Restrict console login to users in admin or sudo group # Restrict console login to users in admin or sudo group
try: try:
set_restricted_access(True) set_restricted_access(True)
except Exception: except Exception as error:
messages.error(self.request, messages.error(
_('Failed to restrict console access.')) self.request,
_('Failed to restrict console access: {error}'.format(
error=error)))
return user return user

View File

@ -8,6 +8,8 @@
{% block content %} {% block content %}
<h3>{%trans "Create User" %}</h3>
<form class="form" method="post"> <form class="form" method="post">
{% csrf_token %} {% csrf_token %}

View File

@ -7,7 +7,11 @@
{% load i18n %} {% load i18n %}
{% block content %} {% block content %}
<h3>{{ object.username }}</h3> <h3>
{% blocktrans trimmed with username=object.username %}
Edit User <em>{{ username }}</em>
{% endblocktrans %}
</h3>
<p> <p>
{% url 'users:change_password' object.username as change_password_url %} {% url 'users:change_password' object.username as change_password_url %}

View File

@ -16,6 +16,7 @@ import pytest
from plinth import action_utils from plinth import action_utils
from plinth.modules import security from plinth.modules import security
from plinth.tests import config as test_config
_cleanup_users = None _cleanup_users = None
_cleanup_groups = None _cleanup_groups = None
@ -74,8 +75,8 @@ def _try_login_to_ssh(username, password, returncode=0):
def _action_file(): def _action_file():
"""Return the path to the 'users' actions file.""" """Return the path to the 'users' actions file."""
current_directory = pathlib.Path(__file__).parent current_directory = pathlib.Path(__file__).parent
return str( return str(current_directory / '..' / '..' / '..' / '..' / 'actions' /
current_directory / '..' / '..' / '..' / '..' / 'actions' / 'users') 'users')
@pytest.fixture(name='disable_restricted_access', autouse=True) @pytest.fixture(name='disable_restricted_access', autouse=True)
@ -111,8 +112,8 @@ def fixture_auto_cleanup_users_groups(needs_root, load_cfg):
def _call_action(arguments, **kwargs): def _call_action(arguments, **kwargs):
"""Call the action script.""" """Call the action script."""
kwargs['stdout'] = kwargs.get('stdout', subprocess.DEVNULL) kwargs['stdout'] = kwargs.get('stdout', subprocess.PIPE)
kwargs['stderr'] = kwargs.get('stderr', subprocess.DEVNULL) kwargs['stderr'] = kwargs.get('stderr', subprocess.PIPE)
kwargs['check'] = kwargs.get('check', True) kwargs['check'] = kwargs.get('check', True)
return subprocess.run([_action_file()] + arguments, **kwargs) return subprocess.run([_action_file()] + arguments, **kwargs)
@ -120,13 +121,19 @@ def _call_action(arguments, **kwargs):
def _create_user(username=None, groups=None): def _create_user(username=None, groups=None):
"""Call the action script for creating a new user.""" """Call the action script for creating a new user."""
username = username or _random_string() username = username or _random_string()
password = _random_string() password = username + '_passwd'
admin_user, admin_password = _get_admin_user_password()
_call_action(['create-user', username], input=password.encode()) process_input = "{0}\n{1}".format(password, admin_password).encode()
_call_action(['create-user', '--auth-user', admin_user, username],
input=process_input)
if groups: if groups:
for group in groups: for group in groups:
_call_action(['add-user-to-group', username, group]) admin_user, admin_password = _get_admin_user_password()
_call_action([
'add-user-to-group', '--auth-user', admin_user, username, group
], input=admin_password.encode())
if group != 'admin': if group != 'admin':
_cleanup_groups.add(group) _cleanup_groups.add(group)
@ -136,29 +143,54 @@ def _create_user(username=None, groups=None):
def _delete_user(username): def _delete_user(username):
"""Utility to delete an LDAP and Samba user""" """Utility to delete an LDAP and Samba user"""
_call_action(['remove-user', username]) process_input = None
if _get_group_users('admin') == [username]:
_, admin_password = _get_admin_user_password()
process_input = admin_password.encode()
_call_action(['remove-user', username], input=process_input)
def _get_admin_user_password():
"""Return an admin username and password."""
admin_users = _get_group_users('admin')
if not admin_users:
return ('', '')
if test_config.admin_username in admin_users:
return (test_config.admin_username, test_config.admin_password)
return (admin_users[0], admin_users[0] + '_passwd')
def _rename_user(old_username, new_username=None): def _rename_user(old_username, new_username=None):
"""Rename a user.""" """Rename a user."""
new_username = new_username or _random_string() new_username = new_username or _random_string()
_call_action(['rename-user', old_username, new_username]) _call_action(['rename-user', old_username, new_username])
_cleanup_users.remove(old_username) _cleanup_users.remove(old_username)
_cleanup_users.add(new_username) _cleanup_users.add(new_username)
return new_username return new_username
def _get_group_users(group):
"""Return the list of members in a group."""
process = _call_action(['get-group-users', group])
return process.stdout.decode().split()
def _get_user_groups(username): def _get_user_groups(username):
"""Return the list of groups for a user.""" """Return the list of groups for a user."""
process = _call_action(['get-user-groups', username], process = _call_action(['get-user-groups', username])
stdout=subprocess.PIPE)
return process.stdout.decode().split() return process.stdout.decode().split()
def _create_group(groupname=None): def _create_group(groupname=None):
groupname = groupname or _random_string() groupname = groupname or _random_string()
_call_action(['create-group', groupname]) _call_action(['create-group', groupname])
_cleanup_groups.add(groupname) if groupname != 'admin':
_cleanup_groups.add(groupname)
return groupname return groupname
@ -181,7 +213,13 @@ def test_change_user_password():
username, old_password = _create_user(groups=['admin']) username, old_password = _create_user(groups=['admin'])
old_password_hash = _get_password_hash(username) old_password_hash = _get_password_hash(username)
new_password = 'pass $123' new_password = 'pass $123'
_call_action(['set-user-password', username], input=new_password.encode())
admin_user, admin_password = _get_admin_user_password()
process_input = "{0}\n{1}".format(new_password, admin_password).encode()
_call_action(['set-user-password', username, '--auth-user', admin_user],
input=process_input)
new_password_hash = _get_password_hash(username) new_password_hash = _get_password_hash(username)
assert old_password_hash != new_password_hash assert old_password_hash != new_password_hash
@ -191,6 +229,38 @@ def test_change_user_password():
assert _try_login_to_ssh(username, new_password) assert _try_login_to_ssh(username, new_password)
def test_change_password_as_non_admin_user():
"""Test changing user password as a non-admin user."""
username, old_password = _create_user()
old_password_hash = _get_password_hash(username)
new_password = 'pass $123'
process_input = "{0}\n{1}".format(new_password, old_password).encode()
_call_action(['set-user-password', username, '--auth-user', username],
input=process_input)
new_password_hash = _get_password_hash(username)
assert old_password_hash != new_password_hash
# User can login to ssh using new password but not the old password.
# sshpass gives a return code of 5 if the password is incorrect.
assert _try_login_to_ssh(username, old_password, returncode=5)
assert _try_login_to_ssh(username, new_password)
def test_change_other_users_password_as_non_admin():
"""Test that changing other user's password as a non-admin user fails."""
username1, password1 = _create_user()
username2, _ = _create_user()
new_password = 'pass $123'
process_input = "{0}\n{1}".format(new_password, password1).encode()
with pytest.raises(subprocess.CalledProcessError):
_call_action(
['set-user-password', username2, '--auth-user', username1],
input=process_input)
def test_set_password_for_non_existent_user(): def test_set_password_for_non_existent_user():
"""Test setting password for a non-existent user.""" """Test setting password for a non-existent user."""
non_existent_user = _random_string() non_existent_user = _random_string()
@ -202,6 +272,9 @@ def test_set_password_for_non_existent_user():
def test_rename_user(): def test_rename_user():
"""Test whether renaming a user works.""" """Test whether renaming a user works."""
# create an admin user to create other users with
_create_user(groups=['admin'])
old_username, password = _create_user(groups=['admin', _random_string()]) old_username, password = _create_user(groups=['admin', _random_string()])
old_groups = _get_user_groups(old_username) old_groups = _get_user_groups(old_username)
@ -269,24 +342,40 @@ def test_groups():
assert _is_exit_zero([_action_file(), 'remove-group', groupname]) assert _is_exit_zero([_action_file(), 'remove-group', groupname])
def test_delete_admin_group_fails():
"""Test that deleting the admin group fails."""
groupname = 'admin'
_create_group('admin')
assert not _is_exit_zero([_action_file(), 'remove-group', groupname])
def test_user_group_interactions(): def test_user_group_interactions():
"""Test adding/removing user from a groups.""" """Test adding/removing user from a groups."""
group1 = _random_string() group1 = _random_string()
user1, _ = _create_user(groups=[group1]) user1, _ = _create_user(groups=[group1])
assert [group1] == _get_user_groups(user1) assert [group1] == _get_user_groups(user1)
admin_user, admin_password = _get_admin_user_password()
# add-user-to-group is not idempotent # add-user-to-group is not idempotent
with pytest.raises(subprocess.CalledProcessError): with pytest.raises(subprocess.CalledProcessError):
_call_action(['add-user-to-group', user1, group1]) _call_action(
['add-user-to-group', '--auth-user', admin_user, user1, group1],
input=admin_password.encode())
# The same user can be added to other new groups # The same user can be added to other new groups
group2 = _random_string() group2 = _random_string()
_create_group(group2) _create_group(group2)
_call_action(['add-user-to-group', user1, group2]) _call_action(
['add-user-to-group', '--auth-user', admin_user, user1, group2],
input=admin_password.encode())
# Adding a user to a non-existent group creates the group # Adding a user to a non-existent group creates the group
group3 = _random_string() group3 = _random_string()
_call_action(['add-user-to-group', user1, group3]) _call_action(
['add-user-to-group', '--auth-user', admin_user, user1, group3],
input=admin_password.encode())
_cleanup_groups.add(group3) _cleanup_groups.add(group3)
# The expected groups got created and the user is part of them. # The expected groups got created and the user is part of them.
@ -295,7 +384,10 @@ def test_user_group_interactions():
# Remove user from group # Remove user from group
group_to_remove_from = random.choice(expected_groups) group_to_remove_from = random.choice(expected_groups)
_call_action(['remove-user-from-group', user1, group_to_remove_from]) _call_action([
'remove-user-from-group', '--auth-user', admin_user, user1,
group_to_remove_from
], input=admin_password.encode())
# User is no longer in the group that they're removed from # User is no longer in the group that they're removed from
expected_groups.remove(group_to_remove_from) expected_groups.remove(group_to_remove_from)
@ -305,4 +397,7 @@ def test_user_group_interactions():
random_group = _random_string() random_group = _random_string()
_create_group(random_group) _create_group(random_group)
with pytest.raises(subprocess.CalledProcessError): with pytest.raises(subprocess.CalledProcessError):
_call_action(['remove-user-from-group', user1, random_group]) _call_action([
'remove-user-from-group', '--auth-user', admin_user, user1,
random_group
], input=admin_password.encode())

View File

@ -3,10 +3,18 @@
Functional, browser based tests for users app. Functional, browser based tests for users app.
""" """
import random
import string
import subprocess
import urllib
import pytest
from pytest_bdd import given, parsers, scenarios, then, when from pytest_bdd import given, parsers, scenarios, then, when
from plinth.tests import functional from plinth.tests import functional
_admin_password = functional.config['DEFAULT']['password']
scenarios('users.feature') scenarios('users.feature')
_language_codes = { _language_codes = {
@ -51,11 +59,63 @@ def new_user_does_not_exist(session_browser, name):
@given(parsers.parse('the user {name:w} exists')) @given(parsers.parse('the user {name:w} exists'))
def test_user_exists(session_browser, name): def test_user_exists(session_browser, name):
functional.nav_to_module(session_browser, 'users')
user_link = session_browser.find_link_by_href(
'/plinth/sys/users/{}/edit/'.format(name))
if user_link:
_delete_user(session_browser, name)
_create_user(session_browser, name, _random_string())
@given(
parsers.parse('the admin user {name:w} with password {password:w} exists'))
def test_admin_user_exists(session_browser, name, password):
functional.nav_to_module(session_browser, 'users') functional.nav_to_module(session_browser, 'users')
user_link = session_browser.find_link_by_href('/plinth/sys/users/' + name + user_link = session_browser.find_link_by_href('/plinth/sys/users/' + name +
'/edit/') '/edit/')
if not user_link: if user_link:
create_user(session_browser, name, 'secret123') _delete_user(session_browser, name)
_create_user(session_browser, name, password, is_admin=True)
@given(parsers.parse('the user {name:w} with password {password:w} exists'))
def user_exists(session_browser, name, password):
functional.nav_to_module(session_browser, 'users')
user_link = session_browser.find_link_by_href('/plinth/sys/users/' + name +
'/edit/')
if user_link:
_delete_user(session_browser, name)
_create_user(session_browser, name, password)
@given(
parsers.parse(
"I'm logged in as the user {username:w} with password {password:w}"))
def logged_in_user_with_account(session_browser, username, password):
functional.login_with_account(session_browser, functional.base_url,
username, password)
@given(parsers.parse('the ssh keys are {ssh_keys:w}'))
def ssh_keys(session_browser, ssh_keys):
_set_ssh_keys(session_browser, ssh_keys)
@given('the client has a ssh key')
def generate_ssh_keys(session_browser, tmp_path_factory):
key_file = tmp_path_factory.getbasetemp() / 'users-ssh.key'
try:
key_file.unlink()
except FileNotFoundError:
pass
subprocess.check_call(
['ssh-keygen', '-t', 'ed25519', '-N', '', '-q', '-f',
str(key_file)])
@when( @when(
@ -74,6 +134,123 @@ def delete_user(session_browser, name):
_delete_user(session_browser, name) _delete_user(session_browser, name)
@when('I change the language to <language>')
def change_language(session_browser, language):
_set_language(session_browser, _language_codes[language])
@when(parsers.parse('I change the ssh keys to {ssh_keys:w}'))
def change_ssh_keys(session_browser, ssh_keys):
_set_ssh_keys(session_browser, ssh_keys)
@when('I remove the ssh keys')
def remove_ssh_keys(session_browser):
_set_ssh_keys(session_browser, '')
@when(
parsers.parse(
'I change the ssh keys to {ssh_keys:w} for the user {username:w}'))
def change_user_ssh_keys(session_browser, ssh_keys, username):
_set_ssh_keys(session_browser, ssh_keys, username=username)
@when(
parsers.parse(
'I change my ssh keys to {ssh_keys:w} with password {password:w}'))
def change_my_ssh_keys(session_browser, ssh_keys, password):
_set_ssh_keys(session_browser, ssh_keys, password=password)
@when(parsers.parse('I set the user {username:w} as inactive'))
def set_user_inactive(session_browser, username):
_set_user_inactive(session_browser, username)
@when(
parsers.parse(
'I change my password from {current_password} to {new_password:w}'))
def change_my_password(session_browser, current_password, new_password):
_change_password(session_browser, new_password,
current_password=current_password)
@when(
parsers.parse(
'I change the user {username:w} password to {new_password:w}'))
def change_other_user_password(session_browser, username, new_password):
_change_password(session_browser, new_password, username=username)
@when('I configure the ssh keys')
def configure_ssh_keys(session_browser, tmp_path_factory):
public_key_file = tmp_path_factory.getbasetemp() / 'users-ssh.key.pub'
public_key = public_key_file.read_text()
_set_ssh_keys(session_browser, public_key)
@then(
parsers.parse(
'I can log in as the user {username:w} with password {password:w}'))
def can_log_in(session_browser, username, password):
functional.visit(session_browser, '/plinth/accounts/logout/')
functional.login_with_account(session_browser, functional.base_url,
username, password)
assert len(session_browser.find_by_id('id_user_menu')) > 0
@then(
parsers.parse(
"I can't log in as the user {username:w} with password {password:w}"))
def cannot_log_in(session_browser, username, password):
functional.visit(session_browser, '/plinth/accounts/logout/')
functional.login_with_account(session_browser, functional.base_url,
username, password)
assert len(session_browser.find_by_id('id_user_menu')) == 0
@then('Plinth language should be <language>')
def plinth_language_should_be(session_browser, language):
assert _check_language(session_browser, _language_codes[language])
@then(parsers.parse('the ssh keys should be {ssh_keys:w}'))
def ssh_keys_match(session_browser, ssh_keys):
assert _get_ssh_keys(session_browser) == ssh_keys
@then('the ssh keys should be removed')
def ssh_keys_should_be_removed(session_browser, ssh_keys):
assert _get_ssh_keys(session_browser) == ''
@then(
parsers.parse(
'the ssh keys should be {ssh_keys:w} for the user {username:w}'))
def ssh_keys_match_for_user(session_browser, ssh_keys, username):
assert _get_ssh_keys(session_browser, username=username) == ssh_keys
@then(parsers.parse('my ssh keys should be {ssh_keys:w}'))
def my_ssh_keys_match(session_browser, ssh_keys):
assert _get_ssh_keys(session_browser) == ssh_keys
@then('the client should be able to connect passwordless over ssh')
def should_connect_passwordless_over_ssh(session_browser, tmp_path_factory):
key_file = tmp_path_factory.getbasetemp() / 'users-ssh.key'
_try_login_to_ssh(key_file=key_file)
@then("the client shouldn't be able to connect passwordless over ssh")
def should_not_connect_passwordless_over_ssh(session_browser,
tmp_path_factory):
key_file = tmp_path_factory.getbasetemp() / 'users-ssh.key'
with pytest.raises(subprocess.CalledProcessError):
_try_login_to_ssh(key_file=key_file)
@then(parsers.parse('{name:w} should be listed as a user')) @then(parsers.parse('{name:w} should be listed as a user'))
def new_user_is_listed(session_browser, name): def new_user_is_listed(session_browser, name):
assert _is_user(session_browser, name) assert _is_user(session_browser, name)
@ -84,23 +261,16 @@ def new_user_is_not_listed(session_browser, name):
assert not _is_user(session_browser, name) assert not _is_user(session_browser, name)
@when('I change the language to <language>') def _create_user(browser, name, password, is_admin=False):
def change_language(session_browser, language):
_set_language(session_browser, _language_codes[language])
@then('Plinth language should be <language>')
def plinth_language_should_be(session_browser, language):
assert _check_language(session_browser, _language_codes[language])
def _create_user(browser, name, password):
functional.nav_to_module(browser, 'users') functional.nav_to_module(browser, 'users')
with functional.wait_for_page_update(browser): with functional.wait_for_page_update(browser):
browser.find_link_by_href('/plinth/sys/users/create/').first.click() browser.find_link_by_href('/plinth/sys/users/create/').first.click()
browser.find_by_id('id_username').fill(name) browser.find_by_id('id_username').fill(name)
browser.find_by_id('id_password1').fill(password) browser.find_by_id('id_password1').fill(password)
browser.find_by_id('id_password2').fill(password) browser.find_by_id('id_password2').fill(password)
if is_admin:
browser.find_by_id('id_groups_0').check()
browser.find_by_id('id_confirm_password').fill(_admin_password)
functional.submit(browser) functional.submit(browser)
@ -110,6 +280,7 @@ def _rename_user(browser, old_name, new_name):
browser.find_link_by_href('/plinth/sys/users/' + old_name + browser.find_link_by_href('/plinth/sys/users/' + old_name +
'/edit/').first.click() '/edit/').first.click()
browser.find_by_id('id_username').fill(new_name) browser.find_by_id('id_username').fill(new_name)
browser.find_by_id('id_confirm_password').fill(_admin_password)
functional.submit(browser) functional.submit(browser)
@ -135,6 +306,7 @@ def _set_language(browser, language_code):
functional.visit(browser, '/plinth/sys/users/{}/edit/'.format(username)) functional.visit(browser, '/plinth/sys/users/{}/edit/'.format(username))
browser.find_by_xpath('//select[@id="id_language"]//option[@value="' + browser.find_by_xpath('//select[@id="id_language"]//option[@value="' +
language_code + '"]').first.click() language_code + '"]').first.click()
browser.find_by_id('id_confirm_password').fill(_admin_password)
functional.submit(browser) functional.submit(browser)
@ -142,3 +314,73 @@ def _check_language(browser, language_code):
functional.nav_to_module(browser, 'config') functional.nav_to_module(browser, 'config')
return browser.find_by_css('.app-titles').first.find_by_tag( return browser.find_by_css('.app-titles').first.find_by_tag(
'h2').first.value == _config_page_title_language_map[language_code] 'h2').first.value == _config_page_title_language_map[language_code]
def _get_ssh_keys(browser, username=None):
functional.visit(browser, '/plinth/')
if username is None:
browser.find_by_id('id_user_menu').click()
browser.find_by_id('id_user_edit_menu').click()
else:
functional.visit(browser,
'/plinth/sys/users/{}/edit/'.format(username))
return browser.find_by_id('id_ssh_keys').text
def _random_string(length=8):
"""Return a random string created from lower case ascii."""
return ''.join(
[random.choice(string.ascii_lowercase) for _ in range(length)])
def _set_ssh_keys(browser, ssh_keys, username=None, password=None):
if username is None:
browser.find_by_id('id_user_menu').click()
browser.find_by_id('id_user_edit_menu').click()
else:
functional.visit(browser,
'/plinth/sys/users/{}/edit/'.format(username))
password = password or _admin_password
browser.find_by_id('id_ssh_keys').fill(ssh_keys)
browser.find_by_id('id_confirm_password').fill(password)
functional.submit(browser)
def _set_user_inactive(browser, username):
functional.visit(browser, '/plinth/sys/users/{}/edit/'.format(username))
browser.find_by_id('id_is_active').uncheck()
browser.find_by_id('id_confirm_password').fill(_admin_password)
functional.submit(browser)
def _change_password(browser, new_password, current_password=None,
username=None):
current_password = current_password or _admin_password
if username is None:
browser.find_by_id('id_user_menu').click()
browser.find_by_id('id_change_password_menu').click()
else:
functional.visit(
browser, '/plinth/sys/users/{}/change_password/'.format(username))
browser.find_by_id('id_new_password1').fill(new_password)
browser.find_by_id('id_new_password2').fill(new_password)
browser.find_by_id('id_confirm_password').fill(current_password)
functional.submit(browser)
def _try_login_to_ssh(key_file=None):
user = functional.config['DEFAULT']['username']
hostname = urllib.parse.urlparse(
functional.config['DEFAULT']['url']).hostname
port = functional.config['DEFAULT']['ssh_port']
subprocess.check_call([
'ssh', '-p', port, '-i', key_file, '-q', '-o',
'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', '-o',
'BatchMode=yes', '{0}@{1}'.format(user, hostname), '/bin/true'
])

View File

@ -67,6 +67,7 @@ def make_request(request, view, as_admin=True, **kwargs):
user = User.objects.create(username='tester') user = User.objects.create(username='tester')
admin_user = User.objects.create(username='admin') admin_user = User.objects.create(username='admin')
admin_user.set_password('adminpassword')
request.user = admin_user if as_admin else user request.user = admin_user if as_admin else user
@ -99,7 +100,8 @@ def test_create_user_view(rf, username):
form_data = { form_data = {
'username': username, 'username': username,
'password1': password, 'password1': password,
'password2': password 'password2': password,
'confirm_password': 'adminpassword',
} }
request = rf.post(urls.reverse('users:create'), data=form_data) request = rf.post(urls.reverse('users:create'), data=form_data)
@ -111,6 +113,31 @@ def test_create_user_view(rf, username):
assert response.url == urls.reverse('users:index') assert response.url == urls.reverse('users:index')
@pytest.mark.parametrize('password,error', [
('', {
'confirm_password': ['This field is required.']
}),
('wrong_password', {
'confirm_password': ['Invalid password.']
}),
])
def test_create_user_invalid_admin_view(rf, password, error):
"""Test that user creation with an invalid admin password fails."""
user_password = 'testingtesting'
form_data = {
'username': 'test-new',
'password1': user_password,
'password2': user_password,
'confirm_password': password,
}
request = rf.post(urls.reverse('users:create'), data=form_data)
view = views.UserCreate.as_view()
response, messages = make_request(request, view)
assert response.context_data['form'].errors == error
def test_create_user_form_view(rf): def test_create_user_form_view(rf):
"""Test that a username field on create form has correct attributes.""" """Test that a username field on create form has correct attributes."""
request = rf.get(urls.reverse('users:create')) request = rf.get(urls.reverse('users:create'))
@ -133,7 +160,8 @@ def test_create_user_invalid_username_view(rf, username):
form_data = { form_data = {
'username': username, 'username': username,
'password1': 'testingtesting', 'password1': 'testingtesting',
'password2': 'testingtesting' 'password2': 'testingtesting',
'confirm_password': 'adminpassword',
} }
request = rf.post(urls.reverse('users:create'), data=form_data) request = rf.post(urls.reverse('users:create'), data=form_data)
@ -157,7 +185,8 @@ def test_create_user_taken_or_reserved_username_view(rf, username, error):
form_data = { form_data = {
'username': username, 'username': username,
'password1': 'testingtesting', 'password1': 'testingtesting',
'password2': 'testingtesting' 'password2': 'testingtesting',
'confirm_password': 'adminpassword',
} }
request = rf.post(urls.reverse('users:create'), data=form_data) request = rf.post(urls.reverse('users:create'), data=form_data)
@ -175,7 +204,8 @@ def test_update_user_view(rf):
form_data = { form_data = {
'username': new_username, 'username': new_username,
'password1': 'testingtesting', 'password1': 'testingtesting',
'password2': 'testingtesting' 'password2': 'testingtesting',
'confirm_password': 'adminpassword',
} }
url = urls.reverse('users:edit', kwargs={'slug': user}) url = urls.reverse('users:edit', kwargs={'slug': user})
@ -189,12 +219,40 @@ def test_update_user_view(rf):
kwargs={'slug': new_username}) kwargs={'slug': new_username})
@pytest.mark.parametrize('password,error', [
('', {
'confirm_password': ['This field is required.']
}),
('wrong_password', {
'confirm_password': ['Invalid password.']
}),
])
def test_update_user_invalid_admin_view(rf, password, error):
"""Test that updating username with an invalid admin password fails."""
user = 'tester'
new_username = 'tester-renamed'
form_data = {
'username': new_username,
'password1': 'testingtesting',
'password2': 'testingtesting',
'confirm_password': password,
}
url = urls.reverse('users:edit', kwargs={'slug': user})
request = rf.post(url, data=form_data)
view = views.UserUpdate.as_view()
response, messages = make_request(request, view, as_admin=True, slug=user)
assert response.context_data['form'].errors == error
def test_update_user_without_permissions_view(rf): def test_update_user_without_permissions_view(rf):
"""Test that updating other user as non-admin user raises exception.""" """Test that updating other user as non-admin user raises exception."""
form_data = { form_data = {
'username': 'admin-renamed', 'username': 'admin-renamed',
'password1': 'testingtesting', 'password1': 'testingtesting',
'password2': 'testingtesting' 'password2': 'testingtesting',
'confirm_password': 'adminpassword',
} }
url = urls.reverse('users:edit', kwargs={'slug': 'admin'}) url = urls.reverse('users:edit', kwargs={'slug': 'admin'})
@ -224,7 +282,8 @@ def test_user_change_password_view(rf):
user = 'admin' user = 'admin'
form_data = { form_data = {
'new_password1': 'testingtesting2', 'new_password1': 'testingtesting2',
'new_password2': 'testingtesting2' 'new_password2': 'testingtesting2',
'confirm_password': 'adminpassword',
} }
url = urls.reverse('users:change_password', kwargs={'slug': user}) url = urls.reverse('users:change_password', kwargs={'slug': user})
@ -237,6 +296,32 @@ def test_user_change_password_view(rf):
assert response.url == urls.reverse('users:edit', kwargs={'slug': user}) assert response.url == urls.reverse('users:edit', kwargs={'slug': user})
@pytest.mark.parametrize('password,error', [
('', {
'confirm_password': ['This field is required.']
}),
('wrong_password', {
'confirm_password': ['Invalid password.']
}),
])
def test_user_change_password_invalid_admin_view(rf, password, error):
"""Test that changing password with an invalid admin password fails."""
user = 'admin'
form_data = {
'new_password1': 'testingtesting2',
'new_password2': 'testingtesting2',
'confirm_password': password,
}
url = urls.reverse('users:change_password', kwargs={'slug': user})
request = rf.post(url, data=form_data)
view = views.UserChangePassword.as_view()
response, messages = make_request(request, view, as_admin=True, slug=user)
assert response.context_data['form'].errors == error
assert response.status_code == 200
def test_user_change_password_without_permissions_view(rf): def test_user_change_password_without_permissions_view(rf):
""" """
Test that changing other user password as a non-admin user raises Test that changing other user password as a non-admin user raises
@ -245,7 +330,8 @@ def test_user_change_password_without_permissions_view(rf):
user = 'admin' user = 'admin'
form_data = { form_data = {
'new_password1': 'adminadmin2', 'new_password1': 'adminadmin2',
'new_password2': 'adminadmin2' 'new_password2': 'adminadmin2',
'confirm_password': 'adminpassword',
} }
url = urls.reverse('users:change_password', kwargs={'slug': user}) url = urls.reverse('users:change_password', kwargs={'slug': user})

View File

@ -2,11 +2,6 @@
# TODO Scenario: Add user to wiki group # TODO Scenario: Add user to wiki group
# TODO Scenario: Remove user from wiki group # TODO Scenario: Remove user from wiki group
# TODO Scenario: Set user SSH key
# TODO Scenario: Clear user SSH key
# TODO Scenario: Make user inactive
# TODO Scenario: Make user active
# TODO Scenario: Change user password
@system @essential @users @system @essential @users
Feature: Users and Groups Feature: Users and Groups
@ -27,10 +22,39 @@ Scenario: Rename user
Then alice should not be listed as a user Then alice should not be listed as a user
Then bob should be listed as a user Then bob should be listed as a user
Scenario: Delete user Scenario: Admin users can change their own ssh keys
When I change the ssh keys to somekey123
Then the ssh keys should be somekey123
Scenario: Non-admin users can change their own ssh keys
Given the user alice with password secret123secret123 exists
And I'm logged in as the user alice with password secret123secret123
When I change my ssh keys to somekey456 with password secret123secret123
Then my ssh keys should be somekey456
Scenario: Admin users can change other user's ssh keys
Given the user alice exists Given the user alice exists
When I delete the user alice When I change the ssh keys to alicesomekey123 for the user alice
Then alice should not be listed as a user Then the ssh keys should be alicesomekey123 for the user alice
Scenario: Users can remove ssh keys
Given the ssh keys are somekey123
When I remove the ssh keys
Then the ssh keys should be removed
Scenario: Users can connect passwordless over ssh if the keys are set
Given the ssh application is enabled
And the client has a ssh key
When I configure the ssh keys
Then the client should be able to connect passwordless over ssh
Scenario: Users can't connect passwordless over ssh if the keys aren't set
Given the ssh application is enabled
And the client has a ssh key
And the ssh keys are configured
When I remove the ssh keys
Then the client shouldn't be able to connect passwordless over ssh
Scenario Outline: Change language Scenario Outline: Change language
When I change the language to <language> When I change the language to <language>
@ -52,3 +76,30 @@ Scenario Outline: Change language
| Türkçe | | Türkçe |
| | | |
| None | | None |
Scenario: Admin users can set other users an inactive
Given the user alice with password secret789secret789 exists
When I set the user alice as inactive
Then I can't log in as the user alice with password secret789secret789
Scenario: Admin users can change their own password
Given the admin user testadmin with password testingtesting123 exists
And I'm logged in as the user testadmin with password testingtesting123
When I change my password from testingtesting123 to testingtesting456
Then I can log in as the user testadmin with password testingtesting456
Scenario: Admin user can change other user's password
Given the user alice exists
When I change the user alice password to secretsecret567
Then I can log in as the user alice with password secretsecret567
Scenario: Non-admin users can change their own password
Given the user alice with password secret123secret123 exists
And I'm logged in as the user alice with password secret123secret123
When I change my password from secret123secret123 to secret456secret456
Then I can log in as the user alice with password secret456secret456
Scenario: Delete user
Given the user alice exists
When I delete the user alice
Then alice should not be listed as a user

View File

@ -25,6 +25,7 @@ from .forms import (CreateUserForm, FirstBootForm, UserChangePasswordForm,
class ContextMixin(object): class ContextMixin(object):
"""Mixin to add 'title' to the template context.""" """Mixin to add 'title' to the template context."""
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Add self.title to template context.""" """Add self.title to template context."""
context = super(ContextMixin, self).get_context_data(**kwargs) context = super(ContextMixin, self).get_context_data(**kwargs)

View File

@ -146,7 +146,7 @@
{% include "help-menu.html" %} {% include "help-menu.html" %}
<li class="dropdown"> <li id="id_user_menu" class="dropdown">
<a href="{% url 'users:edit' request.user.username %}" <a href="{% url 'users:edit' request.user.username %}"
class="dropdown-toggle" data-toggle="dropdown" class="dropdown-toggle" data-toggle="dropdown"
role="button" aria-expanded="false"> role="button" aria-expanded="false">
@ -156,13 +156,15 @@
</a> </a>
<ul class="dropdown-menu" role="menu"> <ul class="dropdown-menu" role="menu">
<li> <li>
<a href="{% url 'users:edit' request.user.username %}" <a id="id_user_edit_menu"
href="{% url 'users:edit' request.user.username %}"
title="{% trans "Edit"%}"> title="{% trans "Edit"%}">
{% trans "Edit" %} {% trans "Edit" %}
</a> </a>
</li> </li>
<li> <li>
<a href="{% url 'users:change_password' request.user.username %}" <a id='id_change_password_menu'
href="{% url 'users:change_password' request.user.username %}"
title="{% trans "Change password" %}"> title="{% trans "Change password" %}">
{% trans "Change password" %} {% trans "Change password" %}
</a> </a>

View File

@ -14,6 +14,12 @@ backups_ssh_password = None
backups_ssh_keyfile = None backups_ssh_keyfile = None
backups_ssh_repo_uuid = 'plinth_test_sshfs' # will be mounted to /media/<uuid> backups_ssh_repo_uuid = 'plinth_test_sshfs' # will be mounted to /media/<uuid>
# An existing admin account for privileged actions. If this admin account
# doesn't exist and no other admin accounts exist, a random admin account is
# created and deleted afterwards by the tests.
admin_username = 'tester'
admin_password = 'testingtesting'
# Import config_local to override the default variables # Import config_local to override the default variables
try: try:
from .config_local import * # noqa, pylint: disable=unused-import from .config_local import * # noqa, pylint: disable=unused-import

View File

@ -22,6 +22,8 @@ config.read(pathlib.Path(__file__).with_name('config.ini'))
config['DEFAULT']['url'] = os.environ.get('FREEDOMBOX_URL', config['DEFAULT']['url'] = os.environ.get('FREEDOMBOX_URL',
config['DEFAULT']['url']).rstrip('/') config['DEFAULT']['url']).rstrip('/')
config['DEFAULT']['ssh_port'] = os.environ.get('FREEDOMBOX_SSH_PORT',
config['DEFAULT']['ssh_port'])
config['DEFAULT']['samba_port'] = os.environ.get( config['DEFAULT']['samba_port'] = os.environ.get(
'FREEDOMBOX_SAMBA_PORT', config['DEFAULT']['samba_port']) 'FREEDOMBOX_SAMBA_PORT', config['DEFAULT']['samba_port'])
@ -246,9 +248,13 @@ def login_with_account(browser, url, username, password):
if '/plinth/' not in browser.url or '/jsxc/jsxc' in browser.url: if '/plinth/' not in browser.url or '/jsxc/jsxc' in browser.url:
browser.visit(url) browser.visit(url)
apps_link = browser.find_link_by_href('/plinth/apps/') user_menu = browser.find_by_id('id_user_menu')
if len(apps_link):
return if len(user_menu):
if user_menu.text == username:
return
visit(browser, '/plinth/accounts/logout/')
login_button = browser.find_link_by_href('/plinth/accounts/login/') login_button = browser.find_link_by_href('/plinth/accounts/login/')
if login_button: if login_button:

View File

@ -2,4 +2,5 @@
url = https://localhost:4430 url = https://localhost:4430
username = tester username = tester
password = testingtesting password = testingtesting
ssh_port = 2222
samba_port = 4450 samba_port = 4450

View File

@ -12,7 +12,6 @@ import string
from distutils.version import LooseVersion from distutils.version import LooseVersion
import markupsafe import markupsafe
import pam
import ruamel.yaml import ruamel.yaml
from django.utils.functional import lazy from django.utils.functional import lazy
@ -68,6 +67,7 @@ def is_user_admin(request, cached=False):
class YAMLFile(object): class YAMLFile(object):
"""A context management class for updating YAML files""" """A context management class for updating YAML files"""
def __init__(self, yaml_file): def __init__(self, yaml_file):
"""Return a context object for the YAML file. """Return a context object for the YAML file.
@ -161,5 +161,6 @@ def is_axes_old():
def is_authenticated_user(username, password): def is_authenticated_user(username, password):
"""Return true if the user authentication succeeds.""" """Return true if the user authentication succeeds."""
import pam # Minimize dependencies for running tests
pam_authenticator = pam.pam() pam_authenticator = pam.pam()
return bool(pam_authenticator.authenticate(username, password)) return bool(pam_authenticator.authenticate(username, password))