FreedomBox/plinth/modules/users/tests/test_actions.py
Veiko Aasa dfaf009d3c
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>
2020-10-05 00:05:44 -07:00

404 lines
13 KiB
Python

#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Test module to exercise user actions.
it is recommended to run this module with root privileges in a virtual machine.
"""
import pathlib
import random
import re
import string
import subprocess
import pytest
from plinth import action_utils
from plinth.modules import security
from plinth.tests import config as test_config
_cleanup_users = None
_cleanup_groups = None
pytestmark = pytest.mark.usefixtures('needs_root', 'load_cfg')
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 _is_exit_zero(args):
"""Return whether a command gave exit code zero"""
process = subprocess.run(args, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, check=False)
return process.returncode == 0
def _get_password_hash(username):
"""Query and return the password hash of the given LDAP username"""
query = [
'ldapsearch', '-L', '-L', '-L', '-Y', 'EXTERNAL', '-H', 'ldapi:///',
'-b', 'ou=users,dc=thisbox', '-Q', '(cn={})'.format(username),
'userPassword'
]
process = subprocess.run(query, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, check=True)
return process.stdout.decode().strip().split()[-1]
def _get_samba_users():
"""Get users from the Samba user database."""
stdout = subprocess.check_output(
['tdbdump', '/var/lib/samba/private/passdb.tdb']).decode()
return re.findall(r'USER_(.*)\\0', stdout)
def _try_login_to_ssh(username, password, returncode=0):
"""Return whether the sshpass returncode matches when trying to
login to ssh using the given username and password"""
if not action_utils.service_is_running('ssh'):
return True
command = [
'sshpass', '-p', password, 'ssh', '-o', 'UserKnownHostsFile=/dev/null',
'-o', 'StrictHostKeyChecking=no', '-o', 'VerifyHostKeyDNS=no',
username + '@127.0.0.1', '/bin/true'
]
process = subprocess.run(command, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, check=False)
return process.returncode == returncode
def _action_file():
"""Return the path to the 'users' actions file."""
current_directory = pathlib.Path(__file__).parent
return str(current_directory / '..' / '..' / '..' / '..' / 'actions' /
'users')
@pytest.fixture(name='disable_restricted_access', autouse=True)
def fixture_disable_restricted_access(needs_root, load_cfg):
"""Disable console login restrictions."""
restricted_access = security.get_restricted_access_enabled()
if restricted_access:
security.set_restricted_access(False)
yield
security.set_restricted_access(True)
else:
yield
@pytest.fixture(name='auto_cleanup_users_groups', autouse=True)
def fixture_auto_cleanup_users_groups(needs_root, load_cfg):
"""Remove all the users and groups created during tests."""
global _cleanup_users, _cleanup_groups
_cleanup_users = set()
_cleanup_groups = set()
yield
for user in _cleanup_users:
try:
_delete_user(user)
except Exception:
pass
for group in _cleanup_groups:
_delete_group(group)
def _call_action(arguments, **kwargs):
"""Call the action script."""
kwargs['stdout'] = kwargs.get('stdout', subprocess.PIPE)
kwargs['stderr'] = kwargs.get('stderr', subprocess.PIPE)
kwargs['check'] = kwargs.get('check', True)
return subprocess.run([_action_file()] + arguments, **kwargs)
def _create_user(username=None, groups=None):
"""Call the action script for creating a new user."""
username = username or _random_string()
password = username + '_passwd'
admin_user, admin_password = _get_admin_user_password()
process_input = "{0}\n{1}".format(password, admin_password).encode()
_call_action(['create-user', '--auth-user', admin_user, username],
input=process_input)
if groups:
for group in groups:
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':
_cleanup_groups.add(group)
_cleanup_users.add(username)
return username, password
def _delete_user(username):
"""Utility to delete an LDAP and Samba user"""
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):
"""Rename a user."""
new_username = new_username or _random_string()
_call_action(['rename-user', old_username, new_username])
_cleanup_users.remove(old_username)
_cleanup_users.add(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):
"""Return the list of groups for a user."""
process = _call_action(['get-user-groups', username])
return process.stdout.decode().split()
def _create_group(groupname=None):
groupname = groupname or _random_string()
_call_action(['create-group', groupname])
if groupname != 'admin':
_cleanup_groups.add(groupname)
return groupname
def _delete_group(groupname):
_call_action(['remove-group', groupname])
def test_create_user():
"""Test whether creating a new user works."""
username, password = _create_user(groups=['admin', _random_string()])
# assert_can_login_to_console(username, password)
assert _try_login_to_ssh(username, password)
assert username in _get_samba_users()
with pytest.raises(subprocess.CalledProcessError):
_create_user(username)
def test_change_user_password():
"""Test changing user password."""
username, old_password = _create_user(groups=['admin'])
old_password_hash = _get_password_hash(username)
new_password = 'pass $123'
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)
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_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():
"""Test setting password for a non-existent user."""
non_existent_user = _random_string()
fake_password = _random_string().encode()
with pytest.raises(subprocess.CalledProcessError):
_call_action(['set-user-password', non_existent_user],
input=fake_password)
def test_rename_user():
"""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_groups = _get_user_groups(old_username)
new_username = _rename_user(old_username)
assert _try_login_to_ssh(new_username, password)
assert _try_login_to_ssh(old_username, password, returncode=5)
assert old_username not in _get_samba_users()
new_groups = _get_user_groups(new_username)
old_users_groups = _get_user_groups(old_username)
assert not old_users_groups # empty
assert old_groups == new_groups
with pytest.raises(subprocess.CalledProcessError):
_rename_user(old_username)
# Renaming a non-existent user fails
random_username = _random_string()
with pytest.raises(subprocess.CalledProcessError):
_rename_user(random_username, new_username=_random_string())
# Renaming to an existing user fails
existing_user, _ = _create_user()
with pytest.raises(subprocess.CalledProcessError):
_rename_user(existing_user, new_username=new_username)
def test_delete_user():
"""Test to check whether LDAP users can be deleted"""
username, password = _create_user(groups=[_random_string()])
_delete_user(username)
groups_after = _get_user_groups(username)
assert not groups_after # User gets removed from all groups
# User account cannot be found after deletion
assert not _is_exit_zero(['ldapid', username])
# Deleted user cannot login to ssh
assert _try_login_to_ssh(username, password, returncode=5)
assert username not in _get_samba_users()
def test_delete_non_existent_user():
"""Deleting a non-existent user should fail."""
non_existent_user = _random_string()
with pytest.raises(subprocess.CalledProcessError):
_call_action(['delete-user', non_existent_user])
def test_groups():
"""Test to check that LDAP groups can be deleted"""
groupname = _random_string()
_create_group(groupname)
assert _is_exit_zero(['ldapgid', groupname])
# create-group is idempotent
assert _is_exit_zero([_action_file(), 'create-group', groupname])
_delete_group(groupname)
assert not _is_exit_zero(['ldapgid', groupname])
# delete-group is idempotent
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():
"""Test adding/removing user from a groups."""
group1 = _random_string()
user1, _ = _create_user(groups=[group1])
assert [group1] == _get_user_groups(user1)
admin_user, admin_password = _get_admin_user_password()
# add-user-to-group is not idempotent
with pytest.raises(subprocess.CalledProcessError):
_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
group2 = _random_string()
_create_group(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
group3 = _random_string()
_call_action(
['add-user-to-group', '--auth-user', admin_user, user1, group3],
input=admin_password.encode())
_cleanup_groups.add(group3)
# The expected groups got created and the user is part of them.
expected_groups = [group1, group2, group3]
assert expected_groups == _get_user_groups(user1)
# Remove user from group
group_to_remove_from = random.choice(expected_groups)
_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
expected_groups.remove(group_to_remove_from)
assert expected_groups == _get_user_groups(user1)
# User cannot be removed from a group that they're not part of
random_group = _random_string()
_create_group(random_group)
with pytest.raises(subprocess.CalledProcessError):
_call_action([
'remove-user-from-group', '--auth-user', admin_user, user1,
random_group
], input=admin_password.encode())