FreedomBox/plinth/utils.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

167 lines
4.5 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Miscellaneous utility methods.
"""
import gzip
import importlib
import os
import random
import re
import string
from distutils.version import LooseVersion
import markupsafe
import ruamel.yaml
from django.utils.functional import lazy
Version = LooseVersion # Abstraction over distutils.version.LooseVersion
def import_from_gi(library, version):
"""Import and return a GObject introspection library."""
try:
import gi as package
package_name = 'gi'
except ImportError:
import pgi as package
package_name = 'pgi'
package.require_version(library, version)
return importlib.import_module(package_name + '.repository.' + library)
def _format_lazy(string, *args, **kwargs):
"""Lazily format a lazy string."""
allow_markup = kwargs.pop('allow_markup', False)
string = str(string)
string = string.format(*args, **kwargs)
if allow_markup:
string = markupsafe.Markup(string)
return string
format_lazy = lazy(_format_lazy, str)
def non_admin_view(func):
"""Decorator to mark a view as accessible by non-admin users."""
setattr(func, 'IS_NON_ADMIN', True)
return func
def is_user_admin(request, cached=False):
"""Return whether user is an administrator."""
if not request.user.is_authenticated:
return False
if 'cache_user_is_admin' in request.session and cached:
return request.session['cache_user_is_admin']
user_is_admin = request.user.groups.filter(name='admin').exists()
request.session['cache_user_is_admin'] = user_is_admin
return user_is_admin
class YAMLFile(object):
"""A context management class for updating YAML files"""
def __init__(self, yaml_file):
"""Return a context object for the YAML file.
Parameters:
yaml_file - the YAML file to update.
updating the YAML file.
"""
self.yaml_file = yaml_file
self.conf = None
def __enter__(self):
with open(self.yaml_file, 'r') as intro_conf:
if not self.is_file_empty():
self.conf = ruamel.yaml.round_trip_load(intro_conf)
else:
self.conf = {}
return self.conf
def __exit__(self, type_, value, traceback):
if not traceback:
with open(self.yaml_file, 'w') as intro_conf:
ruamel.yaml.round_trip_dump(self.conf, intro_conf)
def is_file_empty(self):
return os.stat(self.yaml_file).st_size == 0
def random_string(size=8):
"""Generate a random alphanumeric string."""
chars = (random.SystemRandom().choice(string.ascii_letters)
for _ in range(size))
return ''.join(chars)
def generate_password(size=32):
"""Generate a random password using ascii alphabet and digits."""
chars = (random.SystemRandom().choice(string.ascii_letters + string.digits)
for _ in range(size))
return ''.join(chars)
def grep(pattern, file_name):
"""Return lines of a file matching a pattern."""
return [
line.rstrip() for line in open(file_name) if re.search(pattern, line)
]
def gunzip(gzip_file, output_file):
"""Utility to unzip a given gzip file and write it to an output file
gzip_file: string path to a gzip file
output_file: string path to the output file
mode: an octal number to specify file permissions
"""
output_dir = os.path.dirname(output_file)
if not os.path.exists(output_dir):
os.makedirs(output_dir, mode=0o755)
with gzip.open(gzip_file, 'rb') as file_handle:
contents = file_handle.read()
def opener(path, flags):
return os.open(path, flags, mode=0o644)
with open(output_file, 'wb', opener=opener) as file_handle:
file_handle.write(contents)
def is_non_empty_file(file_path):
return os.path.isfile(file_path) and os.path.getsize(file_path) > 0
def is_axes_old():
"""Return true if using django-axes version strictly less than 5.0.0.
XXX: Remove this method and allow code that uses it after django-axes >=
5.0.0 becomes available in Debian stable.
"""
import axes
try:
version = axes.get_version()
except AttributeError:
# axes.get_version() was removed in 5.0.13
return False
return LooseVersion(version) < LooseVersion('5.0')
def is_authenticated_user(username, password):
"""Return true if the user authentication succeeds."""
import pam # Minimize dependencies for running tests
pam_authenticator = pam.pam()
return bool(pam_authenticator.authenticate(username, password))