diff --git a/HACKING.md b/HACKING.md index d4c1a9152..5db6eb580 100644 --- a/HACKING.md +++ b/HACKING.md @@ -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** ```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`. diff --git a/actions/users b/actions/users index bbc26b136..fb91987ef 100755 --- a/actions/users +++ b/actions/users @@ -14,8 +14,9 @@ import sys import augeas -from plinth import action_utils +from plinth import action_utils, utils +INPUT_LINES = None ACCESS_CONF = '/etc/security/access.conf' LDAPSCRIPTS_CONF = '/etc/ldapscripts/freedombox-ldapscripts.conf' @@ -31,10 +32,13 @@ def parse_arguments(): subparser = subparsers.add_parser('create-user', help='Create an LDAP user') 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', 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', help='Rename an LDAP user') @@ -45,6 +49,7 @@ def parse_arguments(): help='Set the password of an LDAP user') subparser.add_argument( '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', help='Create an LDAP group') @@ -65,12 +70,14 @@ def parse_arguments(): help='Add an LDAP user to an LDAP 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('--auth-user', required=False) subparser = subparsers.add_parser('set-user-status', help='Set user as active or inactive') subparser.add_argument('username', help='User to change status') subparser.add_argument('status', choices=['active', 'inactive'], help='New status of the user') + subparser.add_argument('--auth-user', required=True) subparser = subparsers.add_parser( '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('groupname', 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' subparser = subparsers.add_parser('get-group-users', @@ -90,6 +98,38 @@ def parse_arguments(): 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(_): """Perform initial setup of LDAP.""" # Avoid reconfiguration of slapd during module upgrades, because @@ -243,18 +283,36 @@ def disconnect_samba_user(username): raise -def read_password(): - """Read the password from stdin.""" - return ''.join(sys.stdin) +def get_input_lines(): + """Return list of input lines from 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): """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() - set_user_password(arguments.username, password) + set_user_password(username, password) flush_cache() - set_samba_user(arguments.username, password) + set_samba_user(username, password) def subcommand_remove_user(arguments): @@ -262,6 +320,10 @@ def subcommand_remove_user(arguments): username = arguments.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) for group in groups: @@ -314,9 +376,58 @@ def set_samba_user(username, password): def subcommand_set_user_password(arguments): """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() - set_user_password(arguments.username, password) - set_samba_user(arguments.username, password) + set_user_password(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): @@ -371,10 +482,14 @@ def subcommand_create_group(arguments): def subcommand_remove_group(arguments): """Remove an LDAP group.""" - if group_exists(arguments.groupname): - _run(['ldapdeletegroup', arguments.groupname]) + groupname = 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): @@ -385,7 +500,12 @@ def add_user_to_group(username, groupname): def subcommand_add_user_to_group(arguments): """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() @@ -396,31 +516,31 @@ def remove_user_from_group(username, groupname): def subcommand_remove_user_from_group(arguments): """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() - if arguments.groupname == 'freedombox-share': - disconnect_samba_user(arguments.username) + if groupname == 'freedombox-share': + disconnect_samba_user(username) def subcommand_get_group_users(arguments): """Get the list of users of an LDAP group.""" - try: - process = _run(['ldapgid', '-P', arguments.groupname], - 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) + for user in get_group_users(arguments.groupname): + print(user) def subcommand_set_user_status(arguments): """Set the status of the user.""" username = arguments.username status = arguments.status + auth_user = arguments.auth_user + + validate_user(auth_user) if status == 'active': flag = '-e' diff --git a/plinth/modules/users/forms.py b/plinth/modules/users/forms.py index bebb77b74..7f6d51065 100644 --- a/plinth/modules/users/forms.py +++ b/plinth/modules/users/forms.py @@ -6,6 +6,7 @@ import re from django import forms from django.contrib import auth, messages 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.core import validators from django.core.exceptions import ValidationError @@ -71,9 +72,27 @@ USERNAME_FIELD = forms.CharField( '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, plinth.forms.LanguageSelectionFormMixin, - UserCreationForm): + PasswordConfirmForm, UserCreationForm): """Custom user create form. Include options to add user to groups. @@ -95,7 +114,8 @@ class CreateUserForm(ValidNewUsernameCheckMixin, class Meta(UserCreationForm.Meta): """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): """Initialize the form with extra request argument.""" @@ -104,7 +124,7 @@ class CreateUserForm(ValidNewUsernameCheckMixin, self.fields['username'].widget.attrs.update({ 'autofocus': 'autofocus', 'autocapitalize': 'none', - 'autocomplete': 'username' + 'autocomplete': 'username', }) def save(self, commit=True): @@ -114,26 +134,34 @@ class CreateUserForm(ValidNewUsernameCheckMixin, if commit: user.userprofile.language = self.cleaned_data['language'] 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: - actions.superuser_run( - 'users', - ['create-user', user.get_username()], - input=self.cleaned_data['password1'].encode()) - except ActionError: - messages.error(self.request, _('Creating LDAP user failed.')) + actions.superuser_run('users', [ + 'create-user', + user.get_username(), '--auth-user', auth_username + ], input=process_input) + except ActionError as error: + messages.error( + self.request, + _('Creating LDAP user failed: {error}'.format( + error=error))) for group in self.cleaned_data['groups']: try: - actions.superuser_run( - 'users', - ['add-user-to-group', - user.get_username(), group]) - except ActionError: + actions.superuser_run('users', [ + 'add-user-to-group', + user.get_username(), group, '--auth-user', + auth_username + ], input=confirm_password.encode()) + except ActionError as error: messages.error( self.request, - _('Failed to add new user to {group} group.').format( - group=group)) + _('Failed to add new user to {group} group: {error}'). + format(group=group, error=error)) group_object, created = Group.objects.get_or_create(name=group) group_object.user_set.add(user) @@ -141,7 +169,7 @@ class CreateUserForm(ValidNewUsernameCheckMixin, return user -class UserUpdateForm(ValidNewUsernameCheckMixin, +class UserUpdateForm(ValidNewUsernameCheckMixin, PasswordConfirmForm, plinth.forms.LanguageSelectionFormMixin, forms.ModelForm): """When user info is changed, also updates LDAP user.""" username = USERNAME_FIELD @@ -158,7 +186,8 @@ class UserUpdateForm(ValidNewUsernameCheckMixin, class Meta: """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 widgets = { 'groups': plinth.forms.CheckboxSelectMultipleWithReadOnly(), @@ -210,9 +239,11 @@ class UserUpdateForm(ValidNewUsernameCheckMixin, user = super(UserUpdateForm, self).save(commit=False) # Profile is auto saved with user object 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 self.username == self.request.user.username: + if self.username == auth_username: set_language(self.request, None, user.userprofile.language) if commit: @@ -240,8 +271,9 @@ class UserUpdateForm(ValidNewUsernameCheckMixin, try: actions.superuser_run('users', [ 'remove-user-from-group', - user.get_username(), old_group - ]) + user.get_username(), old_group, '--auth-user', + auth_username + ], input=confirm_password.encode()) except ActionError: messages.error(self.request, _('Failed to remove user from group.')) @@ -251,18 +283,23 @@ class UserUpdateForm(ValidNewUsernameCheckMixin, try: actions.superuser_run('users', [ 'add-user-to-group', - user.get_username(), new_group - ]) + user.get_username(), new_group, '--auth-user', + auth_username + ], input=confirm_password.encode()) except ActionError: messages.error(self.request, _('Failed to add user to group.')) try: actions.superuser_run('ssh', [ - 'set-keys', '--username', - user.get_username(), '--keys', - self.cleaned_data['ssh_keys'].strip() - ]) + 'set-keys', + '--username', + user.get_username(), + '--keys', + self.cleaned_data['ssh_keys'].strip(), + '--auth-user', + auth_username, + ], input=confirm_password.encode()) except ActionError: messages.error(self.request, _('Unable to set SSH keys.')) @@ -273,10 +310,13 @@ class UserUpdateForm(ValidNewUsernameCheckMixin, else: status = 'inactive' try: - actions.superuser_run( - 'users', - ['set-user-status', - user.get_username(), status]) + actions.superuser_run('users', [ + 'set-user-status', + user.get_username(), + status, + '--auth-user', + auth_username, + ], input=confirm_password.encode()) except ActionError: messages.error(self.request, _('Failed to change user status.')) @@ -297,7 +337,7 @@ class UserUpdateForm(ValidNewUsernameCheckMixin, return cleaned_data -class UserChangePasswordForm(SetPasswordForm): +class UserChangePasswordForm(PasswordConfirmForm, SetPasswordForm): """Custom form that also updates password for LDAP users.""" def __init__(self, request, *args, **kwargs): @@ -310,12 +350,16 @@ class UserChangePasswordForm(SetPasswordForm): def save(self, commit=True): """Save the user model and change LDAP password as well.""" user = super(UserChangePasswordForm, self).save(commit) + auth_username = self.request.user.username if commit: + process_input = '{0}\n{1}'.format( + self.cleaned_data['new_password1'], + self.cleaned_data['confirm_password']).encode() try: - actions.superuser_run( - 'users', ['set-user-password', - user.get_username()], - input=self.cleaned_data['new_password1'].encode()) + actions.superuser_run('users', [ + 'set-user-password', + user.get_username(), '--auth-user', auth_username + ], input=process_input) except ActionError: messages.error(self.request, _('Changing LDAP user password failed.')) @@ -341,19 +385,25 @@ class FirstBootForm(ValidNewUsernameCheckMixin, auth.forms.UserCreationForm): try: actions.superuser_run( 'users', - ['create-user', user.get_username()], + ['create-user', + user.get_username(), '--auth-user', ''], input=self.cleaned_data['password1'].encode()) - except ActionError: - messages.error(self.request, _('Creating LDAP user failed.')) + except ActionError as error: + messages.error( + self.request, + _('Creating LDAP user failed: {error}'.format( + error=error))) try: actions.superuser_run( 'users', ['add-user-to-group', user.get_username(), 'admin']) - except ActionError: - messages.error(self.request, - _('Failed to add new user to admin group.')) + except ActionError as error: + messages.error( + self.request, + _('Failed to add new user to admin group: {error}'.format( + error=error))) # Create initial Django groups 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 try: set_restricted_access(True) - except Exception: - messages.error(self.request, - _('Failed to restrict console access.')) + except Exception as error: + messages.error( + self.request, + _('Failed to restrict console access: {error}'.format( + error=error))) return user diff --git a/plinth/modules/users/templates/users_create.html b/plinth/modules/users/templates/users_create.html index 96a5204bf..53e2dc4da 100644 --- a/plinth/modules/users/templates/users_create.html +++ b/plinth/modules/users/templates/users_create.html @@ -8,6 +8,8 @@ {% block content %} +