Skip to content

Commit

Permalink
Add ability to manage local user password lifecycle
Browse files Browse the repository at this point in the history
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`
  • Loading branch information
anodos325 committed Nov 6, 2023
1 parent a579634 commit 40f179f
Show file tree
Hide file tree
Showing 5 changed files with 360 additions and 15 deletions.
114 changes: 114 additions & 0 deletions src/freenas/usr/bin/truenas-passwd
Original file line number Diff line number Diff line change
@@ -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()
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
Loading

0 comments on commit 40f179f

Please sign in to comment.