Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to manage local user password lifecycle #12367

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 ###
43 changes: 42 additions & 1 deletion src/middlewared/middlewared/etc_files/shadow.mako
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<%
from datetime import datetime
from middlewared.utils import filter_list
def get_passwd(entry):
Expand All @@ -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):
"""
<last change>:<min>:<max>:<warning>:<inactivity>:<expiration>:<reserved>
"""
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']}):
Expand Down
115 changes: 107 additions & 8 deletions src/middlewared/middlewared/plugins/account.py
Original file line number Diff line number Diff line change
@@ -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
)
Expand All @@ -18,6 +18,7 @@
import errno
import glob
import hashlib
import hmac
import json
import os
import random
Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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'}),
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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']]:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'))
Expand Down Expand Up @@ -692,13 +738,31 @@ 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:
same_user_logged_in = False

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)
Expand Down Expand Up @@ -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_'})

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')

Expand Down
8 changes: 7 additions & 1 deletion src/middlewared/middlewared/plugins/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading