From 996a581d4547afdae181850a9d6e7abcc9b995db Mon Sep 17 00:00:00 2001 From: Andrew Walker Date: Wed, 18 Oct 2023 13:57:03 -0700 Subject: [PATCH] Add ability to manage local user password lifecycle Various regulations and standards require setting password policies for all interactive user accounts. This commit adds support for password aging, account expiration, and password history checking, as well as ability for users to reset their own password via a tool `truenas-passwd` --- src/freenas/usr/bin/truenas-passwd | 114 ++++++++++++ .../middlewared/etc_files/shadow.mako | 43 ++++- .../middlewared/plugins/account.py | 168 ++++++++++++++++-- src/middlewared/middlewared/plugins/etc.py | 9 + tests/api2/test_account_password_policies.py | 41 +++++ 5 files changed, 360 insertions(+), 15 deletions(-) create mode 100755 src/freenas/usr/bin/truenas-passwd create mode 100644 tests/api2/test_account_password_policies.py diff --git a/src/freenas/usr/bin/truenas-passwd b/src/freenas/usr/bin/truenas-passwd new file mode 100755 index 0000000000000..d87461a95d745 --- /dev/null +++ b/src/freenas/usr/bin/truenas-passwd @@ -0,0 +1,114 @@ +#!/usr/bin/python3 + +import os +import pam +import pwd +import signal +import sys +from getpass import getpass +from middlewared.client import Client, ClientException, ValidationErrors + +MAX_RETRIES_OLD_PASSWORD = 2 +MAX_RETRIES_NEW_PASSWORD = 2 + + +def sigint_handler(signum, frame): + """ + This is cosmetic to avoid python tracebacks if user sends SIGINT. + """ + sys.exit(1) + + +def get_current_password(username, retries=0): + """ + Get the current password from the user. Validate using PAM. + libpam inserts a random delay after a failed authenticate + attempt to prevent timing attacks. + """ + if retries > MAX_RETRIES_OLD_PASSWORD: + # User can't remember or doesn't know their current password + # At this point the NAS admin has to get involved. + sys.exit( + f"Failed to validate current password for {username}. " + "Contact the adminstrator for password maintenance " + "assistance." + ) + + password = getpass( + f"Changing password for {username}\n" + "Current password:" + ) + p = pam.pam() + if not p.authenticate(username, password, service="middleware"): + print(f"Failed to validate current password for {username}.") + return get_current_password(username, retries + 1) + + return password + + +def get_new_password(retries=0): + """ + Get the new password from the user. Hopefully being able to + type the same one twice in a row means they're either able to + remember or using a password manager. + """ + if retries > MAX_RETRIES_OLD_PASSWORD: + # User is unable to type in the same string two times in + # a row. At this point someone else should get involved. + sys.exit( + "Contact the administrator for assistance with " + "updating your password." + ) + + first = getpass("New password:") + second = getpass("Repeat new password:") + if first != second: + print("Passwords do not match.") + return get_new_password(retries + 1) + + return second + + +def handle_validation_errors(exc): + """ + ValidationErrors returned by middleware include information about + conditions that prevent user from changing their password to the + specified one. Print all of them out before exiting. + """ + exit_msg = '\n'.join([err.errmsg for err in exc.errors]) + sys.exit(exit_msg) + + +def handle_generic_client_exception(exc): + """ + This is more generic client exception than an explicit validation + error from middleware. The primary reason for this to happen would + be if the user lacks privileges to reset its password. + """ + if exc.error == 'Not authenticated': + sys.exit( + "User lacks privileges to reset its password. Contact " + "the server administrator for assistance with changing " + "the user password." + ) + + sys.exit(exc.error) + +def main(): + username = pwd.getpwuid(os.geteuid()).pw_name + old_password = get_current_password(username) + new_password = get_new_password() + with Client() as c: + try: + c.call('user.reset_password', old_password, new_password) + except ValidationErrors as e: + handle_validation_errors(e) + except ClientException as e: + handle_generic_client_exception(e) + + print(f'{username}: password successfully reset.') + + +if __name__ == '__main__': + signal.signal(signal.SIGINT, sigint_handler) + main() diff --git a/src/middlewared/middlewared/etc_files/shadow.mako b/src/middlewared/middlewared/etc_files/shadow.mako index 3feb28b05dd8c..7549816c31244 100644 --- a/src/middlewared/middlewared/etc_files/shadow.mako +++ b/src/middlewared/middlewared/etc_files/shadow.mako @@ -1,4 +1,5 @@ <% + from datetime import datetime from middlewared.utils import filter_list def get_passwd(entry): @@ -9,9 +10,49 @@ return entry['unixhash'] + def convert_to_days(value): + ts = int(value.strftime('%s')) + return int(ts / 86400) + + def parse_aging(entry): + """ + :::::: + """ + if not entry['password_aging_enabled']: + outstr = ':::::' + if user['account_expiration_date'] is not None: + outstr += str(convert_to_days(user['account_expiration_date'])) + + outstr += ':' + return outstr + + outstr = '' + if user['last_password_change'] is not None: + outstr += str(convert_to_days(user['last_password_change'])) + if user['password_change_required']: + outstr += '0' + outstr += ':' + + for key in [ + 'min_password_age', + 'max_password_age', + 'password_warn_period', + 'password_inactivity_period', + ]: + if user.get(key) is not None: + outstr += str(user[key]) + + outstr += ':' + + if user['account_expiration_date'] is not None: + outstr += str(convert_to_days(user['account_expiration_date'])) + + outstr += ':' + return outstr + %>\ % for user in filter_list(render_ctx['user.query'], [], {'order_by': ['-builtin', 'uid']}): -${user['username']}:${get_passwd(user)}:18397:0:99999:7::: +${user['username']}:${get_passwd(user)}:${parse_aging(user)} % endfor % if render_ctx.get('cluster_healthy'): % for user in filter_list(render_ctx['clustered_users'], [], {'order_by': ['uid']}): diff --git a/src/middlewared/middlewared/plugins/account.py b/src/middlewared/middlewared/plugins/account.py index 984a4c7bd612d..e6c7badddf558 100644 --- a/src/middlewared/middlewared/plugins/account.py +++ b/src/middlewared/middlewared/plugins/account.py @@ -1,6 +1,7 @@ -from middlewared.schema import accepts, Bool, Dict, Int, List, Password, Patch, returns, Str, LocalUsername +from middlewared.schema import accepts, Bool, Datetime, Dict, Int, List, Password, Patch, returns, Str, LocalUsername from middlewared.service import ( - CallError, CRUDService, ValidationErrors, no_auth_required, pass_app, private, filterable, job + CallError, CRUDService, ValidationErrors, no_auth_required, + no_authz_required, pass_app, private, filterable, job ) import middlewared.sqlalchemy as sa from middlewared.utils import run, filter_list @@ -15,6 +16,7 @@ import errno import glob import hashlib +import hmac import json import os import random @@ -25,13 +27,23 @@ import subprocess import time import warnings +from datetime import datetime from pathlib import Path from contextlib import suppress ADMIN_UID = 950 # When googled, does not conflict with anything ADMIN_GID = 950 SKEL_PATH = '/etc/skel/' -DEFAULT_HOME_PATH = '/nonexistent' + +# TrueNAS historically used /nonexistent as the default home directory for new +# users. The nonexistent directory has caused problems when +# 1) an admin chooses to create it from shell +# 2) PAM checks for home directory existence +# And so this default has been deprecated in favor of using /var/empty +# which is an empty and immutable directory. +LEGACY_DEFAULT_HOME_PATH = '/nonexistent' +DEFAULT_HOME_PATH = '/var/empty' +PASSWORD_HISTORY_LEN = 10 def pw_checkname(verrors, attribute, name): @@ -133,6 +145,15 @@ class UserModel(sa.Model): bsdusr_sudo_commands_nopasswd = sa.Column(sa.JSON(list)) bsdusr_group_id = sa.Column(sa.ForeignKey('account_bsdgroups.id'), index=True) bsdusr_email = sa.Column(sa.String(254), nullable=True) + bsdusr_password_aging_enabled = sa.Column(sa.Boolean(), default=False) + bsdusr_password_change_required = sa.Column(sa.Boolean(), default=False) + bsdusr_last_password_change = sa.Column(sa.Integer(), nullable=True) + bsdusr_min_password_age = sa.Column(sa.Integer(), nullable=True) + bsdusr_max_password_age = sa.Column(sa.Integer(), nullable=True) + bsdusr_password_warn_period = sa.Column(sa.Integer(), nullable=True) + bsdusr_password_inactivity_period = sa.Column(sa.Integer(), nullable=True) + bsdusr_account_expiration_date = sa.Column(sa.Integer(), nullable=True) + bsdusr_password_history = sa.Column(sa.EncryptedText(), default=[], nullable=True) class UserService(CRUDService): @@ -147,7 +168,6 @@ class Config: datastore_prefix = 'bsdusr_' cli_namespace = 'account.user' - # FIXME: Please see if dscache can potentially alter result(s) format, without ad, it doesn't seem to ENTRY = Patch( 'user_create', 'user_entry', ('rm', {'name': 'group'}), @@ -166,6 +186,9 @@ class Config: ('add', Str('nt_name', null=True)), ('add', Str('sid', null=True)), ('add', List('roles', items=[Str('role')])), + ('add', Datetime('last_password_change', null=True)), + ('add', Int('password_age', null=True)), + ('add', List('password_history', items=[Password('old_hash')], null=True)), ) @private @@ -186,6 +209,7 @@ async def user_extend_context(self, rows, extra): memberships[uid] = [i['group']['id']] return { + 'now': datetime.utcnow(), 'memberships': memberships, 'user_2fa_mapping': ({ entry['user']['id']: bool(entry['secret']) for entry in await self.middleware.call( @@ -211,9 +235,24 @@ async def user_extend(self, user, ctx): user['groups'] = ctx['memberships'].get(user['id'], []) # Get authorized keys user['sshpubkey'] = await self.middleware.run_in_thread(self._read_authorized_keys, user['home']) + if user['password_history']: + user['password_history'] = user['password_history'].split() + else: + user['password_history'] = [] + + + if user['last_password_change'] not in (None, 0): + user['password_age'] = (ctx['now'] - entry['last_password_change']).days + else: + user['password_age'] = None user['immutable'] = user['builtin'] or (user['username'] == 'admin' and user['home'] == '/home/admin') user['twofactor_auth_configured'] = bool(ctx['user_2fa_mapping'][user['id']]) + for key in ['last_password_change', 'account_expiration_date']: + if user.get(key) is None: + continue + + user[key] = datetime.fromtimestamp(user[key] * 86400) user_roles = set() for g in user['groups']: @@ -241,12 +280,22 @@ def user_compress(self, user): 'immutable', 'home_create', 'roles', + 'password_age', 'twofactor_auth_configured', ] for i in to_remove: user.pop(i, None) + for key in ['last_password_change', 'account_expiration_date']: + if user.get(key) is None: + continue + + user[key] = int(int(user[key].strftime('%s')) / 86400) + + if user.get('password_history') is not None: + user['password_history'] = ' '.join(user['password_history']) + return user @filterable @@ -300,10 +349,8 @@ async def query(self, filters, options): ds_users = await self.middleware.call('dscache.query', 'USERS', filters, options.copy()) # For AD users, we will not have 2FA attribute normalized so let's do that ad_users_2fa_mapping = await self.middleware.call('auth.twofactor.get_ad_users') - for index, user in enumerate(filter( - lambda u: not u['local'] and 'twofactor_auth_configured' not in u, ds_users) - ): - ds_users[index]['twofactor_auth_configured'] = bool(ad_users_2fa_mapping.get(user['sid'])) + for user in ds_users: + user['twofactor_auth_configured'] = bool(ad_users_2fa_mapping.get(user['sid'])) result = await self.middleware.call( 'datastore.query', self._config.datastore, [], datastore_options @@ -358,7 +405,7 @@ def validate_homedir_path(self, verrors, schema, data, users): verrors.add(f'{schema}.home', '"Home Directory" cannot contain colons (:).') return False - if data['home'] == DEFAULT_HOME_PATH: + if data['home'] == DEFAULT_HOME_PATH or data['home'] == LEGACY_DEFAULT_HOME_PATH: return False if not p.exists(): @@ -500,6 +547,13 @@ def setup_homedir(self, path, username, mode, uid, gid, create=False): List('sudo_commands_nopasswd', items=[Str('command', empty=False)]), Str('sshpubkey', null=True, max_length=None), List('groups', items=[Int('group')]), + Bool('password_aging_enabled', default=False), + Bool('password_change_required', default=False), + Int('min_password_age', default=0), + Int('max_password_age', default=0), + Int('password_warn_period', default=None, null=True), + Int('password_inactivity_period', default=None, null=True), + Datetime('account_expiration_date', default=None, null=True), register=True, )) @returns(Int('primary_key')) @@ -584,7 +638,7 @@ def do_create(self, data): new_homedir = False home_mode = data.pop('home_mode') - if data['home'] and data['home'] != DEFAULT_HOME_PATH: + if data['home'] and data['home'] not in (DEFAULT_HOME_PATH, LEGACY_DEFAULT_HOME_PATH): try: data['home'] = self.setup_homedir( data['home'], @@ -674,6 +728,10 @@ def do_update(self, app, pk, data): """ user = self.middleware.call_sync('user.get_instance', pk) + new_unix_hash = False + if (password_aging_enabled := data.get('password_aging_enabled')) is None: + password_aging_enabled = user['password_aging_enabled'] + if app: same_user_logged_in = user['username'] == (self.middleware.call_sync('auth.me', app=app))['pw_name'] else: @@ -681,6 +739,20 @@ def do_update(self, app, pk, data): verrors = ValidationErrors() + if data.get('password'): + new_unix_hash = True + data['last_password_change'] = datetime.utcnow() + data['password_change_required'] = False + if password_aging_enabled: + for hash in user['password_history']: + if hmac.compare_digest(crypt.crypt(data['password'], hash), hash): + verrors.add( + 'user_update.password', + 'Security configuration for this user account requires a password ' + f'that does not match any of the last {PASSWORD_HISTORY_LEN} passwords.' + ) + break + if data.get('password_disabled'): try: self.middleware.call_sync('privilege.before_user_password_disable', user) @@ -724,8 +796,8 @@ def do_update(self, app, pk, data): old_mode = None home = data.get('home') or user['home'] - had_home = user['home'] != DEFAULT_HOME_PATH - has_home = home != DEFAULT_HOME_PATH + had_home = user['home'] not in (DEFAULT_HOME_PATH, LEGACY_DEFAULT_HOME_PATH) + has_home = home != (DEFAULT_HOME_PATH, LEGACY_DEFAULT_HOME_PATH) # root user and admin users are an exception to the rule if data.get('sshpubkey'): if not ( @@ -849,6 +921,15 @@ def do_update(self, app, pk, data): groups = user.pop('groups') self.__set_groups(pk, groups) + if password_aging_enabled and new_unix_hash: + user['password_history'].append(user['unixhash']) + while len(user['password_history']) > PASSWORD_HISTORY_LEN: + user['password_history'].pop(0) + elif not password_aging_enabled: + # Clear out password history since it's not being used and we don't + # want to keep around unneeded hashes. + user['password_history'] = [] + user = self.user_compress(user) self.middleware.call_sync('datastore.update', 'account.bsdusers', pk, user, {'prefix': 'bsdusr_'}) @@ -920,7 +1001,7 @@ def do_delete(self, pk, options): except Exception: self.logger.warn(f'Failed to delete primary group of {user["username"]}', exc_info=True) - if user['home'] and user['home'] != DEFAULT_HOME_PATH: + if user['home'] and user['home'] not in (DEFAULT_HOME_PATH, LEGACY_DEFAULT_HOME_PATH): try: shutil.rmtree(os.path.join(user['home'], '.ssh')) except Exception: @@ -1102,6 +1183,65 @@ async def set_root_password(self, app, password, options): DeprecationWarning) return await self.setup_local_administrator(app, 'root', password, options) + @no_authz_required + @accepts( + Password('old_password', required=True), + Password('new_password', required=True) + ) + @returns() + @pass_app() + async def reset_password(self, app, old_password, new_password): + """ + Reset the password of the currently authenticated user. + + This will raise validation errors in the following situations: + + - current session is authenticated via an API key rather than user account + - `old_password` is incorrect + - password authentication is disabled for the current user + - current user account is locked + - password aging for user is enabled and password matches one of last 10 password + - password aging is enabled and the user changed password too recently + """ + username = (await self.middleware.call('auth.me', app=app))['pw_name'] + verrors = ValidationErrors() + + if not await self.middleware.call('auth.libpam_authenticate', username, old_password): + verrors.add('user.reset_password.old_password', f'{username}: failed to validate password.') + + entry = await self.middleware.call( + 'user.query', [['username', '=', username]], {'get': True} + ) + if entry['password_disabled']: + verrors.add('user.reset_password', f'{username}: password authentication disabled for user') + + if entry['locked']: + verrors.add('user.reset_password', f'{username}: user account is locked.') + + new_hash = crypted_password(new_password) + + if entry['password_aging_enabled']: + for hash in entry['password_history']: + if hmac.compare_digest(crypt.crypt(new_password, hash), hash): + verrors.add( + 'user.reset_password.new_password', + 'Security configuration for this user account requires a password ' + f'that does not match any of the last {PASSWORD_HISTORY_LEN} passwords.' + ) + break + + entry['password_history'].append(new_hash) + while len(entry['password_history']) > PASSWORD_HISTORY_LEN: + entry['password_history'].pop(0) + + verrors.check() + + await self.middleware.call('datastore.update', 'account.bsdusers', entry['id'], { + 'bsdusr_unixhash': new_hash, + 'bsdusr_password_history': ' '.join(entry['password_history']) + }) + await self.middleware.call('etc.generate', 'shadow') + @no_auth_required @accepts( Str('username', enum=['root', 'admin']), @@ -1163,7 +1303,7 @@ async def setup_local_administrator(self, app, username, password, options): @private @job(lock=lambda args: f'copy_home_to_{args[1]}') async def do_home_copy(self, job, home_old, home_new, username, new_mode, uid): - if home_old == DEFAULT_HOME_PATH: + if home_old in (DEFAULT_HOME_PATH, LEGACY_DEFAULT_HOME_PATH): return if new_mode is not None: diff --git a/src/middlewared/middlewared/plugins/etc.py b/src/middlewared/middlewared/plugins/etc.py index 0adc77a5f4b7f..28dd0327ad1fc 100644 --- a/src/middlewared/middlewared/plugins/etc.py +++ b/src/middlewared/middlewared/plugins/etc.py @@ -74,6 +74,15 @@ class EtcService(Service): 'truenas_nvdimm': [ {'type': 'py', 'path': 'truenas_nvdimm', 'checkpoint': 'post_init'}, ], + 'shadow': { + 'ctx': [ + {'method': 'user.query'}, + {'method': 'cluster.utils.is_clustered'} + ], + 'entries': [ + {'type': 'mako', 'path': 'shadow', 'group': 'shadow', 'mode': 0o0640}, + ] + }, 'user': { 'ctx': [ {'method': 'user.query'}, diff --git a/tests/api2/test_account_password_policies.py b/tests/api2/test_account_password_policies.py new file mode 100644 index 0000000000000..d291ac206bea4 --- /dev/null +++ b/tests/api2/test_account_password_policies.py @@ -0,0 +1,41 @@ +import pytest +import secrets +import string + +from middlewared.service_exception import ValidationErrors +from middlewared.test.integration.assets.account import user +from middlewared.test.integration.utils import call, client, ssh + + +USER = 'password_reset_user' +PASSWD1 = ''.join(secrets.choice(string.ascii_letters + string.digits) for i in range(10)) +PASSWD2 = ''.join(secrets.choice(string.ascii_letters + string.digits) for i in range(10)) + +PASSWORD_REUSE_ERR = """ +Security configuration for this user account requires a password that does not match any of the last 10 passwords. +""" + +def test_password_reset(grant_users_password_reset_privilege): + with user({ + 'username': USER, + 'full_name': USER, + 'home': '/var/empty', + 'shell': '/usr/bin/bash', + 'password_aging_enabled': True, + 'ssh_password_enabled': True, + 'password': PASSWD1 + }): + ssh('pwd', user=USER, password=PASSWD1) + + # `user.password_reset` should be allowed + with client(auth=(USER, PASSWD1)) as c: + c.call('user.reset_password', PASSWD1, PASSWD2) + + ssh('pwd', user=USER, password=PASSWD2) + + # Reusing password should raise ValidationError + with pytest.raises(ValidationErrors) as ve: + with client(auth=(USER, PASSWD2)) as c: + c.call('user.reset_password', PASSWD2, PASSWD1) + + assert PASSWORD_REUSE_ERR in str(ve), str(ve)