diff --git a/README.md b/README.md index 75bd8e9..b3acd7f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This repository provides a collection of Ansible playbooks and roles designed to ## Features - **User Management**: Automated user account creation with SSH key management and sudo configuration +- **Two-Factor Authentication**: TOTP-based 2FA (Aegis, Google Authenticator, Authy, Bitwarden, etc...) for SSH login - **Docker Installation**: Rootless Docker setup for enhanced security - **Multi-Environment Support**: Organized inventory structure for different environments - **Testing Framework**: Vagrant-based testing for playbook validation @@ -75,10 +76,12 @@ Manages user accounts with the following features: - Sets up sudo permissions for admin users - Allows limited sudo commands for non-admin users - Forces password change on first login +- **Two-Factor Authentication (2FA)**: Optional TOTP-based 2FA enforcement **Required Variables**: - `SSH_USERLIST`: List of users to create (used by users_add role) - `GENERALINITIALPASSWORD`: Default initial password (use Ansible Vault) +- `ENABLE_2FA`: Enable/disable 2FA globally (true/false) - `AUDITD_ACTION_MAIL_ACCT`: Email address for audit system alerts - `MANAGE_UFW`: Enable/disable UFW firewall management (true/false) - `UFW_OUTGOING_TRAFFIC`: List of allowed outbound traffic rules @@ -93,6 +96,7 @@ SSH_USERLIST: admin: true public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI..." +ENABLE_2FA: true # Enforce TOTP-based 2FA for SSH login AUDITD_ACTION_MAIL_ACCT: "security@example.com" MANAGE_UFW: true UFW_OUTGOING_TRAFFIC: diff --git a/ansible.cfg b/ansible.cfg index e87bb03..665cf66 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -1,5 +1,5 @@ [defaults] -callbacks_enabled = ansible.posix.profile_tasks,ansible.posix.timer +callbacks_enabled = community.general.profile_tasks,community.general.timer remote_tmp = /var/tmp/${USER}/ansible roles_path = ~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles:../roles:./roles:./ ; extended due to timeouts during runs diff --git a/cafe-variome-production-deployment-guide b/cafe-variome-production-deployment-guide new file mode 160000 index 0000000..fca2eb3 --- /dev/null +++ b/cafe-variome-production-deployment-guide @@ -0,0 +1 @@ +Subproject commit fca2eb3764d801999ee9f81688949b9c42ac8b43 diff --git a/docs/security.md b/docs/security.md index d774728..dd05ccd 100644 --- a/docs/security.md +++ b/docs/security.md @@ -203,18 +203,41 @@ sshd_config_settings: AllowGroups: ssh-users ``` -### Multi-Factor Authentication (not yet implemented in these playbooks) +### Multi-Factor Authentication -#### Setting up TOTP (Time-based One-Time Password) -```bash -# Install Google Authenticator PAM module -sudo apt install libpam-google-authenticator +This playbook implements TOTP-based 2FA using Google Authenticator PAM module. + +#### How It Works + +1. **First Login**: User authenticates with SSH key, then sees QR code for 2FA enrollment +2. **Enrollment**: User scans QR code with authenticator app (Aegis, Google Authenticator, Authy, Bitwarden, etc..., etc.) +3. **Subsequent Logins**: User provides SSH key + 6-digit TOTP code + +#### Configuration + +2FA is enabled globally via the `ENABLE_2FA` variable: +```yaml +ENABLE_2FA: true +``` -# Configure for user -google-authenticator -t -d -f -r 3 -R 30 -w 3 +Secrets are stored centrally in `/var/lib/google-authenticator/` (root-owned, mode 0700). + +#### Security Features + +- **Token reuse prevention**: Each code can only be used once (`-d` flag) +- **Rate limiting**: Max 3 attempts per 30 seconds (`-r 3 -R 30`) +- **Time window**: Accepts 3 codes to handle clock skew (`-w 3`) +- **Emergency codes**: 5 one-time backup codes generated + +#### Admin Recovery Process + +If a user loses their 2FA device or needs to re-enroll: + +```bash +# Remove the user's 2FA secret file +sudo rm /var/lib/google-authenticator/username -# PAM configuration (/etc/pam.d/sshd) -auth required pam_google_authenticator.so +# On next SSH login, the user will be prompted to set up 2FA again ``` #### SSH with MFA Configuration diff --git a/roles/users_add/tasks/main.yml b/roles/users_add/tasks/main.yml index b5ef7de..4f578eb 100644 --- a/roles/users_add/tasks/main.yml +++ b/roles/users_add/tasks/main.yml @@ -12,10 +12,11 @@ comment: "{{ item.username }} account. Is admin? {{ item.admin }}" shell: /bin/bash password: "{{ item.initialpassword | default(GENERALINITIALPASSWORD) | password_hash('sha512') }}" - # Sets the initial password that is prompted to change at first ssh login with pubkey (only) + # Initial password required for sudo - must be changed on first login update_password: on_create # This ensures the password is only set when the user is created loop: "{{ users_add_userlist | default([]) }}" - notify: Force password change on first login + notify: + - Force password change on first login no_log: true # Prevent password hashes from being logged - name: Add additional public keys @@ -132,7 +133,7 @@ name: update-notifier-common state: present failed_when: false # Don't fail if package doesn't exist - when: ansible_distribution == "Ubuntu" + when: ansible_facts["distribution"] == "Ubuntu" - name: Create cache directory for MOTD security status ansible.builtin.file: @@ -300,7 +301,7 @@ daemon_reload: true - name: Setup MOTD for Debian systems - when: ansible_os_family == "Debian" + when: ansible_facts["os_family"] == "Debian" block: - name: Check if this is a Debian-based system without update-motd ansible.builtin.command: which update-motd @@ -314,7 +315,7 @@ line: "session optional pam_motd.so motd=/run/motd.dynamic" insertafter: "# Print the message of the day upon successful login" state: present - when: ansible_distribution == "Ubuntu" and users_add_update_motd_exists.rc != 0 + when: ansible_facts["distribution"] == "Ubuntu" and users_add_update_motd_exists.rc != 0 - name: Create PAM MOTD configuration for Debian ansible.builtin.lineinfile: @@ -323,7 +324,7 @@ insertafter: "# Print the message of the day upon successful login" state: present become: true - when: ansible_distribution == "Debian" and users_add_update_motd_exists.rc != 0 + when: ansible_facts["distribution"] == "Debian" and users_add_update_motd_exists.rc != 0 - name: Generate initial security status and dynamic MOTD ansible.builtin.command: /usr/local/bin/update-security-status @@ -338,3 +339,170 @@ mode: '0644' state: touch when: users_add_update_motd_exists.rc != 0 +# =================================================================== +# 2FA (TOTP) CONFIGURATION SECTION +# =================================================================== + +- name: Create no2fa group for service accounts (exempt from 2FA) + ansible.builtin.group: + name: no2fa + state: present + system: true + when: enable_2fa | default(false) + +- name: Add service accounts to no2fa group + ansible.builtin.user: + name: "{{ item.username }}" + groups: no2fa + append: true + loop: "{{ users_add_userlist | default([]) }}" + when: + - enable_2fa | default(false) + - item.enable_2fa is defined + - not item.enable_2fa + +- name: Install libpam-google-authenticator + ansible.builtin.package: + name: libpam-google-authenticator + state: present + when: enable_2fa | default(false) + +- name: Create 2FA secrets directory + ansible.builtin.file: + path: /var/lib/google-authenticator + state: directory + owner: root + group: root + mode: '0700' + when: enable_2fa | default(false) + +- name: Install qrencode for QR code display + ansible.builtin.package: + name: qrencode + state: present + when: enable_2fa | default(false) + +- name: Create 2FA enrollment script (pam_exec approach) + ansible.builtin.copy: + dest: /usr/local/sbin/ga-enroll-if-needed + owner: root + group: root + mode: '0755' + content: | + #!/bin/sh + set -eu + + user="${PAM_USER:?missing PAM_USER}" + base="/var/lib/google-authenticator" + secret="$base/$user" + + # Already enrolled? + if [ -s "$secret" ]; then + exit 0 + fi + + umask 077 + mkdir -p "$base" + chmod 0700 "$base" + + echo "===============================================================================" + echo "SECURITY REQUIREMENT: Two-Factor Authentication (2FA) Setup" + echo "===============================================================================" + echo "You must set up TOTP-based 2FA (Aegis, Google Authenticator, Authy, Bitwarden, etc.)" + echo "Please scan the QR code below with your authenticator app." + echo "Save the emergency scratch codes in a secure location." + echo "" + + # Run enrollment with all options pre-configured + # -t: time-based TOTP + # -d: disallow token reuse (security) + # -f: force write without confirmation + # -r 3: rate limit to 3 attempts + # -R 30: rate limit window of 30 seconds + # -w 3: window of 3 codes (compensates for time skew) + # -s: secret file location + # -e 5: generate 5 emergency codes + # -Q none: suppress google-authenticator QR (we generate our own because it would not display) + # Pipe -1 to skip the code verification prompt (no TTY available in pam_exec) + echo "-1" | google-authenticator -t -d -f -r 3 -R 30 -w 3 -e 5 -Q none -s "$secret" + + # Must exist and be non-empty, otherwise deny login + if [ ! -s "$secret" ]; then + echo "" + echo "2FA enrollment failed or was canceled. Login denied." + exit 1 + fi + + chmod 0600 "$secret" + + # Extract secret key and generate QR code ourselves using qrencode + secret_key=$(head -1 "$secret") + hostname=$(hostname) + otpauth_url="otpauth://totp/${user}@${hostname}?secret=${secret_key}&issuer=${hostname}" + + echo "" + echo "Scan this QR code with your authenticator app:" + echo "" + echo "$otpauth_url" | qrencode -t ANSIUTF8 + echo "" + echo "Or manually enter this secret key: $secret_key" + echo "" + echo "Your emergency scratch codes (save these securely):" + grep -E '^[0-9]{8}$' "$secret" | sed 's/^/ /' + echo "" + echo "2FA setup successful! Please use your TOTP app for future logins." + echo "===============================================================================" + + exit 0 + when: enable_2fa | default(false) + +- name: Configure PAM to force enrollment with pam_exec + ansible.builtin.blockinfile: + path: /etc/pam.d/sshd + insertbefore: "@include common-auth" + marker: "# {mark} ANSIBLE MANAGED BLOCK - 2FA enrollment and authentication" + block: | + # Skip 2FA for service accounts in no2fa group (e.g., ansible automation) + # success=done means auth succeeds immediately if user is in no2fa group + auth [success=done default=ignore] pam_succeed_if.so user ingroup no2fa + # Force 2FA enrollment if secret doesn't exist (denies login if canceled) + auth requisite pam_exec.so stdout /usr/local/sbin/ga-enroll-if-needed + # Require 2FA authentication using centrally-stored secrets + auth required pam_google_authenticator.so user=root secret=/var/lib/google-authenticator/${USER} + state: "{{ 'present' if (enable_2fa | default(false)) else 'absent' }}" + become: true + +- name: Disable password authentication in PAM when 2FA is enabled + ansible.builtin.replace: + path: /etc/pam.d/sshd + regexp: '^(@include common-auth)$' + replace: '# \1 # Disabled for pubkey+2FA authentication' + become: true + when: enable_2fa | default(false) + +- name: Re-enable password authentication in PAM when 2FA is disabled + ansible.builtin.replace: + path: /etc/pam.d/sshd + regexp: '^# (@include common-auth).*Disabled for pubkey\+2FA.*$' + replace: '\1' + become: true + when: not (enable_2fa | default(false)) + +- name: Remove old 2FA shell profile script if it exists + ansible.builtin.file: + path: /etc/profile.d/force-2fa-enrollment.sh + state: absent + +- name: Remove old 2FA enrollment script if it exists + ansible.builtin.file: + path: /usr/local/bin/force-2fa-enrollment + state: absent + +- name: Remove 2FA enrollment from shell profile if disabled + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - /usr/local/bin/force-2fa-enrollment + - /etc/profile.d/force-2fa-enrollment.sh + when: not (enable_2fa | default(false)) diff --git a/setup-playbook.yml b/setup-playbook.yml index 8b2f791..37ba114 100644 --- a/setup-playbook.yml +++ b/setup-playbook.yml @@ -56,6 +56,8 @@ - AUTO_UPDATES_OPTIONS.reboot | type_debug == "bool" - SSHD_ADMIN_NET is defined - SSHD_ADMIN_NET | length > 0 + - ENABLE_2FA is defined + - ENABLE_2FA | type_debug == "bool" fail_msg: | Missing required variables. Please ensure all mandatory variables are defined: - SSH_USERLIST: List of users to create @@ -64,6 +66,7 @@ - UFW_OUTGOING_TRAFFIC: List of allowed outbound traffic rules - AUTO_UPDATES_OPTIONS.reboot: Allow automatic reboots for updates (true/false) - SSHD_ADMIN_NET: List of networks allowed for SSH admin access + - ENABLE_2FA: Enable/disable 2FA globally (true/false) success_msg: "All required variables validation passed" tags: [validation, always] @@ -73,6 +76,7 @@ - "Target host: {{ inventory_hostname }}" - "Environment: {{ environment | default('undefined') }}" - "User management: {{ MANAGE_USERS | default(true) }}" + - "2FA enabled globally: {{ ENABLE_2FA }}" - "Number of users to create: {{ SSH_USERLIST | length }}" tags: [info, always] @@ -89,6 +93,7 @@ become: true vars: users_add_userlist: "{{ SSH_USERLIST }}" + enable_2fa: "{{ ENABLE_2FA }}" when: MANAGE_USERS is not defined or MANAGE_USERS | bool tags: [users, user-management] @@ -170,6 +175,10 @@ sshd_max_auth_tries: "{{ SSHD_MAX_AUTH_TRIES | default(3) }}" # Maximum SSH auth attempts sshd_allow_tcp_forwarding: "{{ SSHD_ALLOW_TCP_FORWARDING | default(false) }}" # Allow SSH port forwarding sshd_client_alive_interval: "{{ SSHD_TIMEOUT_SECS | default(300) }}" # SSH client timeout (5 min default) + sshd_use_pam: "{{ ENABLE_2FA | bool or SSHD_USE_PAM | default(true) }}" + sshd_kbd_interactive_authentication: "{{ ENABLE_2FA | bool or SSHD_KBD_INTERACTIVE_AUTHENTICATION | default(true) }}" + sshd_password_authentication: "{{ SSHD_PASSWORD_AUTHENTICATION | default(false) }}" + sshd_authentication_methods: "{{ 'publickey,keyboard-interactive:pam' if (ENABLE_2FA | default(false) | bool) else 'publickey' }}" # === SYSTEM SECURITY === disable_apport: true # Disable Ubuntu crash reporting @@ -232,7 +241,7 @@ insertafter: "# Print the message of the day upon successful login" state: present become: true - when: ansible_os_family == "Debian" + when: ansible_facts["os_family"] == "Debian" # Post-execution validation and reporting post_tasks: diff --git a/testing/vars.yml b/testing/vars.yml index def6664..fe357c2 100644 --- a/testing/vars.yml +++ b/testing/vars.yml @@ -39,3 +39,4 @@ LOGIND_HARDENING: idleaction: lock idleactionsec: 15min removeipc: true +ENABLE_2FA: false