diff --git a/data/templates/login/pam_otp_ga.conf.j2 b/data/templates/login/pam_otp_ga.conf.j2 new file mode 100644 index 000000000..4c1f411d1 --- /dev/null +++ b/data/templates/login/pam_otp_ga.conf.j2 @@ -0,0 +1,7 @@ +{% if authentication.otp.key is vyos_defined %} +{{ authentication.otp.key }} +" RATE_LIMIT {{ authentication.otp.rate_limit }} {{ authentication.otp.rate_time }} +" WINDOW_SIZE {{ authentication.otp.window_size }} +" DISALLOW_REUSE +" TOTP_AUTH +{% endif %} diff --git a/data/templates/ssh/sshd_config.j2 b/data/templates/ssh/sshd_config.j2 index 79b07478b..5bbfdeb88 100644 --- a/data/templates/ssh/sshd_config.j2 +++ b/data/templates/ssh/sshd_config.j2 @@ -1,102 +1,102 @@ ### Autogenerated by ssh.py ### # https://linux.die.net/man/5/sshd_config # # Non-configurable defaults # Protocol 2 HostKey /etc/ssh/ssh_host_rsa_key HostKey /etc/ssh/ssh_host_dsa_key HostKey /etc/ssh/ssh_host_ecdsa_key HostKey /etc/ssh/ssh_host_ed25519_key SyslogFacility AUTH LoginGraceTime 120 StrictModes yes PubkeyAuthentication yes IgnoreRhosts yes HostbasedAuthentication no PermitEmptyPasswords no -ChallengeResponseAuthentication no X11Forwarding yes X11DisplayOffset 10 PrintMotd no PrintLastLog yes TCPKeepAlive yes Banner /etc/issue.net Subsystem sftp /usr/lib/openssh/sftp-server UsePAM yes PermitRootLogin no PidFile /run/sshd/sshd.pid AddressFamily any DebianBanner no +PasswordAuthentication no # # User configurable section # # Look up remote host name and check that the resolved host name for the remote IP # address maps back to the very same IP address. UseDNS {{ "no" if disable_host_validation is vyos_defined else "yes" }} # Specifies the port number that sshd(8) listens on {% for value in port %} Port {{ value }} {% endfor %} # Gives the verbosity level that is used when logging messages from sshd LogLevel {{ loglevel | upper }} # Specifies whether password authentication is allowed -PasswordAuthentication {{ "no" if disable_password_authentication is vyos_defined else "yes" }} +ChallengeResponseAuthentication {{ "no" if disable_password_authentication is vyos_defined else "yes" }} {% if listen_address is vyos_defined %} # Specifies the local addresses sshd should listen on {% for address in listen_address %} ListenAddress {{ address }} {% endfor %} {% endif %} {% if ciphers is vyos_defined %} # Specifies the ciphers allowed for protocol version 2 Ciphers {{ ciphers | join(',') }} {% endif %} {% if mac is vyos_defined %} # Specifies the available MAC (message authentication code) algorithms MACs {{ mac | join(',') }} {% endif %} {% if key_exchange is vyos_defined %} # Specifies the available Key Exchange algorithms KexAlgorithms {{ key_exchange | join(',') }} {% endif %} {% if access_control is vyos_defined %} {% if access_control.allow.user is vyos_defined %} # If specified, login is allowed only for user names that match AllowUsers {{ access_control.allow.user | join(' ') }} {% endif %} {% if access_control.allow.group is vyos_defined %} # If specified, login is allowed only for users whose primary group or supplementary group list matches AllowGroups {{ access_control.allow.group | join(' ') }} {% endif %} {% if access_control.deny.user is vyos_defined %} # Login is disallowed for user names that match DenyUsers {{ access_control.deny.user | join(' ') }} {% endif %} {% if access_control.deny.group is vyos_defined %} # Login is disallowed for users whose primary group or supplementary group list matches DenyGroups {{ access_control.deny.group | join(' ') }} {% endif %} {% endif %} {% if client_keepalive_interval is vyos_defined %} # Sets a timeout interval in seconds after which if no data has been received from the client, # sshd(8) will send a message through the encrypted channel to request a response from the client ClientAliveInterval {{ client_keepalive_interval }} {% endif %} {% if rekey.data is vyos_defined %} RekeyLimit {{ rekey.data }}M {{ rekey.time + 'M' if rekey.time is vyos_defined }} {% endif %} diff --git a/debian/vyos-1x.postinst b/debian/vyos-1x.postinst index 6879b6e4f..dc64e7a42 100644 --- a/debian/vyos-1x.postinst +++ b/debian/vyos-1x.postinst @@ -1,107 +1,117 @@ #!/bin/sh -e # Turn off Debian default for %sudo sed -i -e '/^%sudo/d' /etc/sudoers || true # Add minion user for salt-minion if ! grep -q '^minion' /etc/passwd; then adduser --quiet --firstuid 100 --system --disabled-login --ingroup vyattacfg \ --gecos "salt minion user" --shell /bin/vbash minion adduser --quiet minion frrvty adduser --quiet minion sudo adduser --quiet minion adm adduser --quiet minion dip adduser --quiet minion disk adduser --quiet minion users adduser --quiet minion frr fi # OpenVPN should get its own user if ! grep -q '^openvpn' /etc/passwd; then adduser --quiet --firstuid 100 --system --group --shell /usr/sbin/nologin openvpn fi +# Add 2FA support for SSH +sudo grep -qF -- "auth required pam_google_authenticator.so nullok" "/etc/pam.d/sshd" || \ +sudo sed -i '/^@include common-auth/a # Check OTP 2FA, if configured for the user\nauth required pam_google_authenticator.so nullok' /etc/pam.d/sshd \ +/ + +# Add 2FA support for local authentication +sudo grep -qF -- "auth required pam_google_authenticator.so nullok" "/etc/pam.d/login" || \ +sudo sed -i '/^@include common-auth/a # Check OTP 2FA, if configured for the user\nauth required pam_google_authenticator.so nullok' /etc/pam.d/login \ +/ + # Add RADIUS operator user for RADIUS authenticated users to map to if ! grep -q '^radius_user' /etc/passwd; then adduser --quiet --firstuid 1000 --disabled-login --ingroup vyattaop \ --no-create-home --gecos "radius user" \ --shell /sbin/radius_shell radius_user adduser --quiet radius_user frrvty adduser --quiet radius_user vyattaop adduser --quiet radius_user operator adduser --quiet radius_user adm adduser --quiet radius_user dip adduser --quiet radius_user users fi # Add RADIUS admin user for RADIUS authenticated users to map to if ! grep -q '^radius_priv_user' /etc/passwd; then adduser --quiet --firstuid 1000 --disabled-login --ingroup vyattacfg \ --no-create-home --gecos "radius privileged user" \ --shell /sbin/radius_shell radius_priv_user adduser --quiet radius_priv_user frrvty adduser --quiet radius_priv_user vyattacfg adduser --quiet radius_priv_user sudo adduser --quiet radius_priv_user adm adduser --quiet radius_priv_user dip adduser --quiet radius_priv_user disk adduser --quiet radius_priv_user users adduser --quiet radius_priv_user frr fi # add hostsd group for vyos-hostsd if ! grep -q '^hostsd' /etc/group; then addgroup --quiet --system hostsd fi # add dhcpd user for dhcp-server if ! grep -q '^dhcpd' /etc/passwd; then adduser --quiet --system --disabled-login --no-create-home --home /run/dhcp-server dhcpd adduser --quiet dhcpd hostsd fi # ensure hte proxy user has a proper shell chsh -s /bin/sh proxy # create /opt/vyatta/etc/config/scripts/vyos-postconfig-bootup.script POSTCONFIG_SCRIPT=/opt/vyatta/etc/config/scripts/vyos-postconfig-bootup.script if [ ! -x $POSTCONFIG_SCRIPT ]; then mkdir -p $(dirname $POSTCONFIG_SCRIPT) touch $POSTCONFIG_SCRIPT chmod 755 $POSTCONFIG_SCRIPT cat <<EOF >>$POSTCONFIG_SCRIPT #!/bin/sh # This script is executed at boot time after VyOS configuration is fully applied. # Any modifications required to work around unfixed bugs # or use services not available through the VyOS CLI system can be placed here. EOF fi # symlink destination is deleted during ISO assembly - this generates some noise # when the system boots: systemd-sysv-generator[1881]: stat() failed on # /etc/init.d/README, ignoring: No such file or directory. Thus we simply drop # the file. if [ -L /etc/init.d/README ]; then rm -f /etc/init.d/README fi # Remove unwanted daemon files from /etc # conntackd # pmacct # fastnetmon # ntp DELETE="/etc/logrotate.d/conntrackd.distrib /etc/init.d/conntrackd /etc/default/conntrackd /etc/default/pmacctd /etc/pmacct /etc/networks_list /etc/networks_whitelist /etc/fastnetmon.conf /etc/ntp.conf /etc/default/ssh /etc/powerdns /etc/default/pdns-recursor" for tmp in $DELETE; do if [ -e ${tmp} ]; then rm -rf ${tmp} fi done # Remove logrotate items controlled via CLI and VyOS defaults sed -i '/^\/var\/log\/messages$/d' /etc/logrotate.d/rsyslog sed -i '/^\/var\/log\/auth.log$/d' /etc/logrotate.d/rsyslog diff --git a/interface-definitions/system-login.xml.in b/interface-definitions/system-login.xml.in index d189be3f8..7dd045e6c 100644 --- a/interface-definitions/system-login.xml.in +++ b/interface-definitions/system-login.xml.in @@ -1,171 +1,247 @@ <?xml version="1.0"?> <interfaceDefinition> <node name="system"> <children> <node name="login" owner="${vyos_conf_scripts_dir}/system-login.py"> <properties> <help>System User Login Configuration</help> <priority>400</priority> </properties> <children> + <node name="authentication"> + <properties> + <help>Global authentication settings</help> + </properties> + <children> + <node name="otp"> + <properties> + <help>2FA OTP authentication parameters</help> + </properties> + <children> + <leafNode name="rate-limit"> + <properties> + <help>Number of attempts. Limit logins to N per every M seconds</help> + <valueHelp> + <format>u32:1-10</format> + <description>Number of attempts. Limit logins to N per every M seconds</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-10"/> + </constraint> + <constraintErrorMessage>Number of login attempts must me between 1 and 10</constraintErrorMessage> + </properties> + <defaultValue>3</defaultValue> + </leafNode> + <leafNode name="rate-time"> + <properties> + <help>Time interval. Limit logins to N per every M seconds</help> + <valueHelp> + <format>u32:15-600</format> + <description>Time interval. Limit logins to N per every M seconds</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 15-600"/> + </constraint> + <constraintErrorMessage>Rate limit time interval must be between 15 and 600 seconds</constraintErrorMessage> + </properties> + <defaultValue>30</defaultValue> + </leafNode> + <leafNode name="window-size"> + <properties> + <help>Set window of concurrently valid codes</help> + <valueHelp> + <format>u32:1-21</format> + <description>Set window of concurrently valid codes</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-21"/> + </constraint> + <constraintErrorMessage>Window of concurrently valid codes must be between 1 and 21</constraintErrorMessage> + </properties> + <defaultValue>3</defaultValue> + </leafNode> + </children> + </node> + </children> + </node> <tagNode name="user"> <properties> <help>Local user account information</help> <constraint> <regex>[-_a-zA-Z0-9.]{1,100}</regex> </constraint> <constraintErrorMessage>Username contains illegal characters or\nexceeds 100 character limitation.</constraintErrorMessage> </properties> <children> <node name="authentication"> <properties> <help>Password authentication</help> </properties> <children> <leafNode name="encrypted-password"> <properties> <help>Encrypted password</help> <constraint> <regex>(\*|\!)</regex> <regex>[a-zA-Z0-9\.\/]{13}</regex> <regex>\$1\$[a-zA-Z0-9\./]*\$[a-zA-Z0-9\./]{22}</regex> <regex>\$5\$[a-zA-Z0-9\./]*\$[a-zA-Z0-9\./]{43}</regex> <regex>\$6\$[a-zA-Z0-9\./]*\$[a-zA-Z0-9\./]{86}</regex> </constraint> <constraintErrorMessage>Invalid encrypted password for $VAR(../../@).</constraintErrorMessage> </properties> <defaultValue>!</defaultValue> </leafNode> + <node name="otp"> + <properties> + <help>2FA OTP authentication parameters</help> + </properties> + <children> + <leafNode name="key"> + <properties> + <help>Token Key Secret key for the token algorithm (see RFC 4226)</help> + <valueHelp> + <format>txt</format> + <description>OTP key (base32 encoded secret)</description> + </valueHelp> + <constraint> + <regex>[a-zA-Z2-7]{20,10000}</regex> + </constraint> + <constraintErrorMessage>Key must only include base32 characters and be at least 26 characters long</constraintErrorMessage> + </properties> + </leafNode> + </children> + </node> <leafNode name="plaintext-password"> <properties> <help>Plaintext password used for encryption</help> </properties> </leafNode> <tagNode name="public-keys"> <properties> <help>Remote access public keys</help> <valueHelp> <format>txt</format> <description>Key identifier used by ssh-keygen (usually of form user@host)</description> </valueHelp> </properties> <children> <leafNode name="key"> <properties> <help>Public key value (Base64 encoded)</help> <constraint> <validator name="base64"/> </constraint> </properties> </leafNode> <leafNode name="options"> <properties> <help>Optional public key options</help> </properties> </leafNode> <leafNode name="type"> <properties> <help>Public key type</help> <completionHelp> <list>ssh-dss ssh-rsa ecdsa-sha2-nistp256 ecdsa-sha2-nistp384 ecdsa-sha2-nistp521 ssh-ed25519</list> </completionHelp> <valueHelp> <format>ssh-dss</format> <description/> </valueHelp> <valueHelp> <format>ssh-rsa</format> <description/> </valueHelp> <valueHelp> <format>ecdsa-sha2-nistp256</format> <description/> </valueHelp> <valueHelp> <format>ecdsa-sha2-nistp384</format> <description/> </valueHelp> <valueHelp> <format>ssh-ed25519</format> <description/> </valueHelp> <constraint> <regex>(ssh-dss|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521|ssh-ed25519)</regex> </constraint> </properties> </leafNode> </children> </tagNode> </children> </node> <leafNode name="full-name"> <properties> <help>Full name of the user (use quotes for names with spaces)</help> <constraint> <regex>[^:]*</regex> </constraint> <constraintErrorMessage>Cannot use ':' in full name</constraintErrorMessage> </properties> </leafNode> <leafNode name="home-directory"> <properties> <help>Home directory</help> </properties> </leafNode> </children> </tagNode> #include <include/radius-server-ipv4-ipv6.xml.i> <node name="radius"> <children> <tagNode name="server"> <children> <leafNode name="timeout"> <properties> <help>Session timeout</help> <valueHelp> <format>u32:1-30</format> <description>Session timeout in seconds</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 1-30"/> </constraint> <constraintErrorMessage>Timeout must be between 1 and 30 seconds</constraintErrorMessage> </properties> <defaultValue>2</defaultValue> </leafNode> <leafNode name="priority"> <properties> <help>Server priority</help> <valueHelp> <format>u32:1-255</format> <description>Server priority</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 1-255"/> </constraint> </properties> <defaultValue>255</defaultValue> </leafNode> </children> </tagNode> #include <include/interface/vrf.xml.i> </children> </node> <leafNode name="timeout"> <properties> <help>Session timeout</help> <valueHelp> <format>u32:5-604800</format> <description>Session timeout in seconds</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 5-604800"/> </constraint> <constraintErrorMessage>Timeout must be between 5 and 604800 seconds</constraintErrorMessage> </properties> </leafNode> </children> </node> </children> </node> </interfaceDefinition> diff --git a/smoketest/scripts/cli/test_system_login.py b/smoketest/scripts/cli/test_system_login.py index 1131b6f93..a99721d66 100755 --- a/smoketest/scripts/cli/test_system_login.py +++ b/smoketest/scripts/cli/test_system_login.py @@ -1,244 +1,260 @@ #!/usr/bin/env python3 # # Copyright (C) 2019-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import re import platform import unittest from base_vyostest_shim import VyOSUnitTestSHIM from distutils.version import LooseVersion from platform import release as kernel_version from subprocess import Popen, PIPE from pwd import getpwall from vyos.configsession import ConfigSessionError from vyos.util import cmd from vyos.util import read_file from vyos.template import inc_ip base_path = ['system', 'login'] users = ['vyos1', 'vyos-roxx123', 'VyOS-123_super.Nice'] ssh_pubkey = """ AAAAB3NzaC1yc2EAAAADAQABAAABgQD0NuhUOEtMIKnUVFIHoFatqX/c4mjerXyF TlXYfVt6Ls2NZZsUSwHbnhK4BKDrPvVZMW/LycjQPzWW6TGtk6UbZP1WqdviQ9hP jsEeKJSTKciMSvQpjBWyEQQPXSKYQC7ryQQilZDqnJgzqwzejKEe+nhhOdBvjuZc uukxjT69E0UmWAwLxzvfiurwiQaC7tG+PwqvtfHOPL3i6yRO2C5ORpFarx8PeGDS IfIXJCr3LoUbLHeuE7T2KaOKQcX0UsWJ4CoCapRLpTVYPDB32BYfgq7cW1Sal1re EGH2PzuXBklinTBgCHA87lHjpwDIAqdmvMj7SXIW9LxazLtP+e37sexE7xEs0cpN l68txdDbY2P2Kbz5mqGFfCvBYKv9V2clM5vyWNy/Xp5TsCis89nn83KJmgFS7sMx pHJz8umqkxy3hfw0K7BRFtjWd63sbOP8Q/SDV7LPaIfIxenA9zv2rY7y+AIqTmSr TTSb0X1zPGxPIRFy5GoGtO9Mm5h4OZk= """ class TestSystemLogin(VyOSUnitTestSHIM.TestCase): def tearDown(self): # Delete individual users from configuration for user in users: self.cli_delete(base_path + ['user', user]) self.cli_commit() # After deletion, a user is not allowed to remain in /etc/passwd usernames = [x[0] for x in getpwall()] for user in users: self.assertNotIn(user, usernames) def test_add_linux_system_user(self): # We are not allowed to re-use a username already taken by the Linux # base system system_user = 'backup' self.cli_set(base_path + ['user', system_user, 'authentication', 'plaintext-password', system_user]) # check validate() - can not add username which exists on the Debian # base system (UID < 1000) with self.assertRaises(ConfigSessionError): self.cli_commit() self.cli_delete(base_path + ['user', system_user]) def test_system_login_user(self): # Check if user can be created and we can SSH to localhost self.cli_set(['service', 'ssh', 'port', '22']) for user in users: name = "VyOS Roxx " + user home_dir = "/tmp/" + user self.cli_set(base_path + ['user', user, 'authentication', 'plaintext-password', user]) self.cli_set(base_path + ['user', user, 'full-name', 'VyOS Roxx']) self.cli_set(base_path + ['user', user, 'home-directory', home_dir]) self.cli_commit() for user in users: cmd = ['su','-', user] proc = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) tmp = "{}\nuname -a".format(user) proc.stdin.write(tmp.encode()) proc.stdin.flush() (stdout, stderr) = proc.communicate() # stdout is something like this: # b'Linux LR1.wue3 5.10.61-amd64-vyos #1 SMP Fri Aug 27 08:55:46 UTC 2021 x86_64 GNU/Linux\n' self.assertTrue(len(stdout) > 40) + def test_system_login_otp(self): + otp_user = 'otp-test_user' + otp_password = 'SuperTestPassword' + otp_key = '76A3ZS6HFHBTOK2H4NDHTIVFPQ' + + self.cli_set(base_path + ['user', otp_user, 'authentication', 'plaintext-password', otp_password]) + self.cli_set(base_path + ['user', otp_user, 'authentication', 'otp', 'key', otp_key]) + + self.cli_commit() + + # Check if OTP key was written properly + tmp = cmd(f'sudo head -1 /home/{otp_user}/.google_authenticator') + self.assertIn(otp_key, tmp) + + self.cli_delete(base_path + ['user', otp_user]) + def test_system_user_ssh_key(self): ssh_user = 'ssh-test_user' public_keys = 'vyos_test@domain-foo.com' type = 'ssh-rsa' self.cli_set(base_path + ['user', ssh_user, 'authentication', 'public-keys', public_keys, 'key', ssh_pubkey.replace('\n','')]) # check validate() - missing type for public-key with self.assertRaises(ConfigSessionError): self.cli_commit() self.cli_set(base_path + ['user', ssh_user, 'authentication', 'public-keys', public_keys, 'type', type]) self.cli_commit() # Check that SSH key was written properly tmp = cmd(f'sudo cat /home/{ssh_user}/.ssh/authorized_keys') key = f'{type} ' + ssh_pubkey.replace('\n','') self.assertIn(key, tmp) self.cli_delete(base_path + ['user', ssh_user]) def test_radius_kernel_features(self): # T2886: RADIUS requires some Kernel options to be present kernel = platform.release() kernel_config = read_file(f'/boot/config-{kernel}') # T2886 - RADIUS authentication - check for statically compiled options options = ['CONFIG_AUDIT', 'CONFIG_AUDITSYSCALL', 'CONFIG_AUDIT_ARCH'] if LooseVersion(kernel_version()) < LooseVersion('5.0'): options.append('CONFIG_AUDIT_WATCH') options.append('CONFIG_AUDIT_TREE') for option in options: self.assertIn(f'{option}=y', kernel_config) def test_system_login_radius_ipv4(self): # Verify generated RADIUS configuration files radius_key = 'VyOSsecretVyOS' radius_server = '172.16.100.10' radius_source = '127.0.0.1' radius_port = '2000' radius_timeout = '1' self.cli_set(base_path + ['radius', 'server', radius_server, 'key', radius_key]) self.cli_set(base_path + ['radius', 'server', radius_server, 'port', radius_port]) self.cli_set(base_path + ['radius', 'server', radius_server, 'timeout', radius_timeout]) self.cli_set(base_path + ['radius', 'source-address', radius_source]) self.cli_set(base_path + ['radius', 'source-address', inc_ip(radius_source, 1)]) # check validate() - Only one IPv4 source-address supported with self.assertRaises(ConfigSessionError): self.cli_commit() self.cli_delete(base_path + ['radius', 'source-address', inc_ip(radius_source, 1)]) self.cli_commit() # this file must be read with higher permissions pam_radius_auth_conf = cmd('sudo cat /etc/pam_radius_auth.conf') tmp = re.findall(r'\n?{}:{}\s+{}\s+{}\s+{}'.format(radius_server, radius_port, radius_key, radius_timeout, radius_source), pam_radius_auth_conf) self.assertTrue(tmp) # required, static options self.assertIn('priv-lvl 15', pam_radius_auth_conf) self.assertIn('mapped_priv_user radius_priv_user', pam_radius_auth_conf) # PAM pam_common_account = read_file('/etc/pam.d/common-account') self.assertIn('pam_radius_auth.so', pam_common_account) pam_common_auth = read_file('/etc/pam.d/common-auth') self.assertIn('pam_radius_auth.so', pam_common_auth) pam_common_session = read_file('/etc/pam.d/common-session') self.assertIn('pam_radius_auth.so', pam_common_session) pam_common_session_noninteractive = read_file('/etc/pam.d/common-session-noninteractive') self.assertIn('pam_radius_auth.so', pam_common_session_noninteractive) # NSS nsswitch_conf = read_file('/etc/nsswitch.conf') tmp = re.findall(r'passwd:\s+mapuid\s+files\s+mapname', nsswitch_conf) self.assertTrue(tmp) tmp = re.findall(r'group:\s+mapname\s+files', nsswitch_conf) self.assertTrue(tmp) def test_system_login_radius_ipv6(self): # Verify generated RADIUS configuration files radius_key = 'VyOS-VyOS' radius_server = '2001:db8::1' radius_source = '::1' radius_port = '4000' radius_timeout = '4' self.cli_set(base_path + ['radius', 'server', radius_server, 'key', radius_key]) self.cli_set(base_path + ['radius', 'server', radius_server, 'port', radius_port]) self.cli_set(base_path + ['radius', 'server', radius_server, 'timeout', radius_timeout]) self.cli_set(base_path + ['radius', 'source-address', radius_source]) self.cli_set(base_path + ['radius', 'source-address', inc_ip(radius_source, 1)]) # check validate() - Only one IPv4 source-address supported with self.assertRaises(ConfigSessionError): self.cli_commit() self.cli_delete(base_path + ['radius', 'source-address', inc_ip(radius_source, 1)]) self.cli_commit() # this file must be read with higher permissions pam_radius_auth_conf = cmd('sudo cat /etc/pam_radius_auth.conf') tmp = re.findall(r'\n?\[{}\]:{}\s+{}\s+{}\s+\[{}\]'.format(radius_server, radius_port, radius_key, radius_timeout, radius_source), pam_radius_auth_conf) self.assertTrue(tmp) # required, static options self.assertIn('priv-lvl 15', pam_radius_auth_conf) self.assertIn('mapped_priv_user radius_priv_user', pam_radius_auth_conf) # PAM pam_common_account = read_file('/etc/pam.d/common-account') self.assertIn('pam_radius_auth.so', pam_common_account) pam_common_auth = read_file('/etc/pam.d/common-auth') self.assertIn('pam_radius_auth.so', pam_common_auth) pam_common_session = read_file('/etc/pam.d/common-session') self.assertIn('pam_radius_auth.so', pam_common_session) pam_common_session_noninteractive = read_file('/etc/pam.d/common-session-noninteractive') self.assertIn('pam_radius_auth.so', pam_common_session_noninteractive) # NSS nsswitch_conf = read_file('/etc/nsswitch.conf') tmp = re.findall(r'passwd:\s+mapuid\s+files\s+mapname', nsswitch_conf) self.assertTrue(tmp) tmp = re.findall(r'group:\s+mapname\s+files', nsswitch_conf) self.assertTrue(tmp) if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system-login.py index dbd346fe4..bd9cc3b89 100755 --- a/src/conf_mode/system-login.py +++ b/src/conf_mode/system-login.py @@ -1,327 +1,340 @@ #!/usr/bin/env python3 # # Copyright (C) 2020-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import os from crypt import crypt from crypt import METHOD_SHA512 from psutil import users from pwd import getpwall from pwd import getpwnam from spwd import getspnam from sys import exit from time import sleep from vyos.config import Config from vyos.configdict import dict_merge from vyos.configverify import verify_vrf from vyos.template import render from vyos.template import is_ipv4 from vyos.util import cmd from vyos.util import call from vyos.util import run from vyos.util import DEVNULL from vyos.util import dict_search from vyos.xml import defaults from vyos import ConfigError from vyos import airbag airbag.enable() autologout_file = "/etc/profile.d/autologout.sh" radius_config_file = "/etc/pam_radius_auth.conf" def get_local_users(): """Return list of dynamically allocated users (see Debian Policy Manual)""" local_users = [] for s_user in getpwall(): uid = getpwnam(s_user.pw_name).pw_uid if uid in range(1000, 29999): if s_user.pw_name not in ['radius_user', 'radius_priv_user']: local_users.append(s_user.pw_name) return local_users def get_config(config=None): if config: conf = config else: conf = Config() base = ['system', 'login'] login = conf.get_config_dict(base, key_mangling=('-', '_'), no_tag_node_value_mangle=True, get_first_key=True) # users no longer existing in the running configuration need to be deleted local_users = get_local_users() cli_users = [] if 'user' in login: cli_users = list(login['user']) # XXX: T2665: we can not safely rely on the defaults() when there are # tagNodes in place, it is better to blend in the defaults manually. default_values = defaults(base + ['user']) for user in login['user']: login['user'][user] = dict_merge(default_values, login['user'][user]) # XXX: T2665: we can not safely rely on the defaults() when there are # tagNodes in place, it is better to blend in the defaults manually. default_values = defaults(base + ['radius', 'server']) for server in dict_search('radius.server', login) or []: login['radius']['server'][server] = dict_merge(default_values, login['radius']['server'][server]) # create a list of all users, cli and users all_users = list(set(local_users + cli_users)) # We will remove any normal users that dos not exist in the current # configuration. This can happen if user is added but configuration was not # saved and system is rebooted. rm_users = [tmp for tmp in all_users if tmp not in cli_users] if rm_users: login.update({'rm_users' : rm_users}) return login def verify(login): if 'rm_users' in login: cur_user = os.environ['SUDO_USER'] if cur_user in login['rm_users']: raise ConfigError(f'Attempting to delete current user: {cur_user}') if 'user' in login: system_users = getpwall() for user, user_config in login['user'].items(): # Linux system users range up until UID 1000, we can not create a # VyOS CLI user which already exists as system user for s_user in system_users: if s_user.pw_name == user and s_user.pw_uid < 1000: raise ConfigError(f'User "{user}" can not be created, conflict with local system account!') for pubkey, pubkey_options in (dict_search('authentication.public_keys', user_config) or {}).items(): if 'type' not in pubkey_options: raise ConfigError(f'Missing type for public-key "{pubkey}"!') if 'key' not in pubkey_options: raise ConfigError(f'Missing key for public-key "{pubkey}"!') # At lease one RADIUS server must not be disabled if 'radius' in login: if 'server' not in login['radius']: raise ConfigError('No RADIUS server defined!') fail = True for server, server_config in dict_search('radius.server', login).items(): if 'key' not in server_config: raise ConfigError(f'RADIUS server "{server}" requires key!') if 'disabled' not in server_config: fail = False continue if fail: raise ConfigError('All RADIUS servers are disabled') verify_vrf(login['radius']) if 'source_address' in login['radius']: ipv4_count = 0 ipv6_count = 0 for address in login['radius']['source_address']: if is_ipv4(address): ipv4_count += 1 else: ipv6_count += 1 if ipv4_count > 1: raise ConfigError('Only one IPv4 source-address can be set!') if ipv6_count > 1: raise ConfigError('Only one IPv6 source-address can be set!') return None def generate(login): # calculate users encrypted password if 'user' in login: for user, user_config in login['user'].items(): tmp = dict_search('authentication.plaintext_password', user_config) if tmp: encrypted_password = crypt(tmp, METHOD_SHA512) login['user'][user]['authentication']['encrypted_password'] = encrypted_password del login['user'][user]['authentication']['plaintext_password'] # remove old plaintext password and set new encrypted password env = os.environ.copy() env['vyos_libexec_dir'] = '/usr/libexec/vyos' # Set default commands for re-adding user with encrypted password del_user_plain = f"system login user '{user}' authentication plaintext-password" add_user_encrypt = f"system login user '{user}' authentication encrypted-password '{encrypted_password}'" lvl = env['VYATTA_EDIT_LEVEL'] # We're in config edit level, for example "edit system login" # Change default commands for re-adding user with encrypted password if lvl != '/': # Replace '/system/login' to 'system login' lvl = lvl.strip('/').split('/') # Convert command str to list del_user_plain = del_user_plain.split() # New command exclude level, for example "edit system login" del_user_plain = del_user_plain[len(lvl):] # Convert string to list del_user_plain = " ".join(del_user_plain) add_user_encrypt = add_user_encrypt.split() add_user_encrypt = add_user_encrypt[len(lvl):] add_user_encrypt = " ".join(add_user_encrypt) call(f"/opt/vyatta/sbin/my_delete {del_user_plain}", env=env) call(f"/opt/vyatta/sbin/my_set {add_user_encrypt}", env=env) else: try: if getspnam(user).sp_pwdp == dict_search('authentication.encrypted_password', user_config): # If the current encrypted bassword matches the encrypted password # from the config - do not update it. This will remove the encrypted # value from the system logs. # # The encrypted password will be set only once during the first boot # after an image upgrade. del login['user'][user]['authentication']['encrypted_password'] except: pass if 'radius' in login: render(radius_config_file, 'login/pam_radius_auth.conf.j2', login, permission=0o600, user='root', group='root') else: if os.path.isfile(radius_config_file): os.unlink(radius_config_file) if 'timeout' in login: render(autologout_file, 'login/autologout.j2', login, permission=0o755, user='root', group='root') else: if os.path.isfile(autologout_file): os.unlink(autologout_file) return None def apply(login): if 'user' in login: for user, user_config in login['user'].items(): # make new user using vyatta shell and make home directory (-m), # default group of 100 (users) command = 'useradd --create-home --no-user-group' # check if user already exists: if user in get_local_users(): # update existing account command = 'usermod' # all accounts use /bin/vbash command += ' --shell /bin/vbash' # we need to use '' quotes when passing formatted data to the shell # else it will not work as some data parts are lost in translation tmp = dict_search('authentication.encrypted_password', user_config) if tmp: command += f" --password '{tmp}'" tmp = dict_search('full_name', user_config) if tmp: command += f" --comment '{tmp}'" tmp = dict_search('home_directory', user_config) if tmp: command += f" --home '{tmp}'" else: command += f" --home '/home/{user}'" command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk {user}' try: cmd(command) # we should not rely on the value stored in # user_config['home_directory'], as a crazy user will choose # username root or any other system user which will fail. # # XXX: Should we deny using root at all? home_dir = getpwnam(user).pw_dir render(f'{home_dir}/.ssh/authorized_keys', 'login/authorized_keys.j2', user_config, permission=0o600, formater=lambda _: _.replace(""", '"'), user=user, group='users') - + #OTP 2FA key file generation + if dict_search('authentication.otp.key', user_config): + user_config['authentication']['otp']['key'] = user_config['authentication']['otp']['key'].upper() + user_config['authentication']['otp']['rate_limit'] = login['authentication']['otp']['rate_limit'] + user_config['authentication']['otp']['rate_time'] = login['authentication']['otp']['rate_time'] + user_config['authentication']['otp']['window_size'] = login['authentication']['otp']['window_size'] + render(f'{home_dir}/.google_authenticator', 'login/pam_otp_ga.conf.j2', + user_config, permission=0o600, + formater=lambda _: _.replace(""", '"'), + user=user, group='users') + #OTP 2FA key file deletion + elif os.path.exists(f'{home_dir}/.google_authenticator'): + os.remove(f'{home_dir}/.google_authenticator') + except Exception as e: raise ConfigError(f'Adding user "{user}" raised exception: "{e}"') if 'rm_users' in login: for user in login['rm_users']: try: # Disable user to prevent re-login call(f'usermod -s /sbin/nologin {user}') # Logout user if he is still logged in if user in list(set([tmp[0] for tmp in users()])): print(f'{user} is logged in, forcing logout!') # re-run command until user is logged out while run(f'pkill -HUP -u {user}'): sleep(0.250) # Remove user account but leave home directory in place. Re-run # command until user is removed - userdel might return 8 as # SSH sessions are not all yet properly cleaned away, thus we # simply re-run the command until the account wen't away while run(f'userdel --remove {user}', stderr=DEVNULL): sleep(0.250) except Exception as e: raise ConfigError(f'Deleting user "{user}" raised exception: {e}') # # RADIUS configuration # env = os.environ.copy() env['DEBIAN_FRONTEND'] = 'noninteractive' try: if 'radius' in login: # Enable RADIUS in PAM cmd('pam-auth-update --package --enable radius', env=env) # Make NSS system aware of RADIUS # This fancy snipped was copied from old Vyatta code command = "sed -i -e \'/\smapname/b\' \ -e \'/^passwd:/s/\s\s*/&mapuid /\' \ -e \'/^passwd:.*#/s/#.*/mapname &/\' \ -e \'/^passwd:[^#]*$/s/$/ mapname &/\' \ -e \'/^group:.*#/s/#.*/ mapname &/\' \ -e \'/^group:[^#]*$/s/: */&mapname /\' \ /etc/nsswitch.conf" else: # Disable RADIUS in PAM cmd('pam-auth-update --package --remove radius', env=env) # Drop RADIUS from NSS NSS system # This fancy snipped was copied from old Vyatta code command = "sed -i -e \'/^passwd:.*mapuid[ \t]/s/mapuid[ \t]//\' \ -e \'/^passwd:.*[ \t]mapname/s/[ \t]mapname//\' \ -e \'/^group:.*[ \t]mapname/s/[ \t]mapname//\' \ -e \'s/[ \t]*$//\' \ /etc/nsswitch.conf" cmd(command) except Exception as e: raise ConfigError(f'RADIUS configuration failed: {e}') return None if __name__ == '__main__': try: c = get_config() verify(c) generate(c) apply(c) except ConfigError as e: print(e) exit(1)