diff --git a/src/middlewared/middlewared/alembic/versions/24.04/2023-12-12_20-25_add-shadow-fields.py b/src/middlewared/middlewared/alembic/versions/24.04/2023-12-12_20-25_add-shadow-fields.py new file mode 100644 index 0000000000000..3215720cbb4ae --- /dev/null +++ b/src/middlewared/middlewared/alembic/versions/24.04/2023-12-12_20-25_add-shadow-fields.py @@ -0,0 +1,54 @@ +"""Add shadow fields + +Revision ID: a270302ec08a +Revises: df1a322df40d +Create Date: 2023-12-12 20:25:28.004544+00:00 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a270302ec08a' +down_revision = 'df1a322df40d' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + with op.batch_alter_table('account_bsdusers', schema=None) as batch_op: + batch_op.add_column(sa.Column('bsdusr_password_aging_enabled', sa.Boolean(), nullable=True)) + batch_op.add_column(sa.Column('bsdusr_password_change_required', sa.Boolean(), nullable=True)) + batch_op.add_column(sa.Column('bsdusr_last_password_change', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('bsdusr_min_password_age', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('bsdusr_max_password_age', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('bsdusr_password_warn_period', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('bsdusr_password_inactivity_period', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('bsdusr_account_expiration_date', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('bsdusr_password_history', sa.Text(), nullable=True)) + + conn.execute('UPDATE account_bsdusers SET bsdusr_password_aging_enabled = ?', False) + conn.execute('UPDATE account_bsdusers SET bsdusr_password_change_required = ?', False) + + with op.batch_alter_table('account_bsdusers', schema=None) as batch_op: + batch_op.alter_column('bsdusr_password_aging_enabled', existing_type=sa.Boolean(), nullable=False) + batch_op.alter_column('bsdusr_password_change_required', existing_type=sa.Boolean(), nullable=False) + + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('account_bsdusers', schema=None) as batch_op: + batch_op.drop_column('bsdusr_password_history') + batch_op.drop_column('bsdusr_account_expiration_date') + batch_op.drop_column('bsdusr_password_inactivity_period') + batch_op.drop_column('bsdusr_password_warn_period') + batch_op.drop_column('bsdusr_max_password_age') + batch_op.drop_column('bsdusr_min_password_age') + batch_op.drop_column('bsdusr_last_password_change') + batch_op.drop_column('bsdusr_password_change_required') + batch_op.drop_column('bsdusr_password_aging_enabled') + + # ### end Alembic commands ### 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 a880f20f32a45..552f0fe04f229 100644 --- a/src/middlewared/middlewared/plugins/account.py +++ b/src/middlewared/middlewared/plugins/account.py @@ -1,4 +1,4 @@ -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, no_authz_required, pass_app, private, filterable, job ) @@ -18,6 +18,7 @@ import errno import glob import hashlib +import hmac import json import os import random @@ -28,12 +29,14 @@ 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/' + # 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 @@ -43,6 +46,7 @@ LEGACY_DEFAULT_HOME_PATH = '/nonexistent' DEFAULT_HOME_PATH = '/var/empty' DEFAULT_HOME_PATHS = (DEFAULT_HOME_PATH, LEGACY_DEFAULT_HOME_PATH) +PASSWORD_HISTORY_LEN = 10 def pw_checkname(verrors, attribute, name): @@ -144,6 +148,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): @@ -158,7 +171,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'}), @@ -177,6 +189,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 @@ -197,6 +212,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( @@ -222,9 +238,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'] + [user['group']['id']]: @@ -252,12 +283,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 @@ -311,10 +352,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 @@ -518,6 +557,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, ), audit='Create user', audit_extended=lambda data: data["username"]) @returns(Int('primary_key')) @@ -692,6 +738,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: @@ -699,6 +749,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) @@ -867,6 +931,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_'}) @@ -1467,6 +1540,8 @@ async def set_password(self, app, data): * account is not local to the NAS (Active Directory, LDAP, etc) * account has password authentication disabled * 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 NOTE: when authenticated session has less than FULL_ADMIN role, password changes will be rejected if the payload does not match the @@ -1544,13 +1619,37 @@ async def set_password(self, app, data): f'{username}: user account is locked.' ) - verrors.check() - entry = self.__set_password(entry | {'password': password}) + if entry['password_aging_enabled']: + for hash in entry['password_history']: + if hmac.compare_digest(entry['unixhash'], hash): + verrors.add( + 'user.set_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 + + if entry['password_age'] < entry['min_password_age']: + verrors.add( + 'user.set_password.username', + f'Current password age of {entry["password_age"]} days is less than the ' + f'configured minimum password age {entry["min_password_age"]} for this ' + 'user account.' + ) + 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': entry['unixhash'], 'bsdusr_smbhash': entry['smbhash'], + 'bsdusr_must_change_password': False, + 'bsdusr_password_history': ' '.join(entry['password_history']), + 'bsdusr_last_password_change': datetime.utcnow() }) await self.middleware.call('etc.generate', 'shadow') diff --git a/src/middlewared/middlewared/plugins/auth.py b/src/middlewared/middlewared/plugins/auth.py index 54029e97e71bc..5803869bf6c0b 100644 --- a/src/middlewared/middlewared/plugins/auth.py +++ b/src/middlewared/middlewared/plugins/auth.py @@ -586,7 +586,13 @@ async def me(self, app): else: attributes = {} - return {**user, 'attributes': attributes} + if local_acct := await self.middleware.call('user.query', [['name', '=', user['pw_uid']]]): + chgpwd = local_acct['password_change_required'] + chgpwd |= (local_acct['password_age'] > local_acct['max_password_age']) + else: + chgpwd = False + + return {**user, 'attributes': attributes, 'password_change_required': chgpwd} @no_authz_required @accepts( diff --git a/tests/api2/test_account_password_policies.py b/tests/api2/test_account_password_policies.py new file mode 100644 index 0000000000000..a94b6d872f622 --- /dev/null +++ b/tests/api2/test_account_password_policies.py @@ -0,0 +1,68 @@ +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 = 'Testpassw0rd1' +PASSWD2 = 'Testpassw0rd2' +PASSWD3 = 'Testpassw0rd3' + +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 + }) as u: + ssh('pwd', user=USER, password=PASSWD1) + + # `user.set_password` should be allowed + with client(auth=(USER, PASSWD1)) as c: + c.call('user.set_password', PASSWD1, PASSWD2) + + # Check that shadow file is updated properly + ssh('pwd', user=USER, password=PASSWD2) + + with client(auth=(USER, PASSWD2)) as c: + # Reusing password should raise ValidationError + with pytest.raises(ValidationErrors) as ve: + c.call('user.set_password', PASSWD2, PASSWD1) + + assert PASSWORD_REUSE_ERR in str(ve), str(ve) + + # Disabling password aging should allow reuse + call('user.update', u['id'], {'password_aging_enabled': False}) + with client(auth=(USER, PASSWD2)) as c: + c.call('user.set_password', PASSWD2, PASSWD1) + + call('user.update', u['id'], { + 'password_aging_enabled': True, + 'must_change_password': True, + }) + with client(auth=(USER, PASSWD1)) as c: + # Verify that setting password removes `must_change_password` flag. + assert c.call('auth.me')['password_change_required'] + c.call('user.set_password', PASSWD1, PASSWD2) + assert c.call('auth.me')['password_change_required'] is False + + call('user.update u['id'], {'min_password_age': 1}) + + # This should fail since it violates minimum password age + # requirement + with pytest.raises(ValidationErrors) as ve: + c.call('user.set_password', PASSWD2, PASSWD3) + + call('user.update u['id'], {'min_password_age': 0}) + c.call('user.set_password', PASSWD2, PASSWD3)