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 2cc1eaace91c8..799f2585e5dbc 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 and app.authenticated_credentials.is_user_session: 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)