From db75038e1c9461564bb0c4b7e863f0628e9c07c2 Mon Sep 17 00:00:00 2001 From: jdaln <150942337+jdaln@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:24:57 +0100 Subject: [PATCH 1/8] feat: 2FA for SSH logins --- roles/users_add/handlers/main.yml | 11 ++++ roles/users_add/tasks/main.yml | 93 ++++++++++++++++++++++++++++++- setup-playbook.yml | 9 +++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/roles/users_add/handlers/main.yml b/roles/users_add/handlers/main.yml index 4270504..46a6766 100644 --- a/roles/users_add/handlers/main.yml +++ b/roles/users_add/handlers/main.yml @@ -5,3 +5,14 @@ loop: "{{ users_add_userlist | default([]) }}" register: chage_output # This will register the output of the command to a variable changed_when: "'Password aging updated' in chage_output.stdout" +- name: Force 2FA enrollment on first login + ansible.builtin.file: + path: "/home/{{ item.username }}/.force_2fa_enrollment" + state: touch + owner: "{{ item.username }}" + group: "{{ item.username }}" + mode: '0600' + loop: "{{ users_add_userlist | default([]) }}" + when: + - ENABLE_2FA | default(false) + - item.enable_2fa | default(true) diff --git a/roles/users_add/tasks/main.yml b/roles/users_add/tasks/main.yml index b5ef7de..0d43848 100644 --- a/roles/users_add/tasks/main.yml +++ b/roles/users_add/tasks/main.yml @@ -15,7 +15,9 @@ # Sets the initial password that is prompted to change at first ssh login with pubkey (only) 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 + - Force 2FA enrollment on first login no_log: true # Prevent password hashes from being logged - name: Add additional public keys @@ -338,3 +340,92 @@ mode: '0644' state: touch when: users_add_update_motd_exists.rc != 0 +# =================================================================== +# 2FA (TOTP) CONFIGURATION SECTION +# =================================================================== + +- name: Install libpam-google-authenticator + ansible.builtin.package: + name: libpam-google-authenticator + state: present + when: ENABLE_2FA | default(false) + +- name: Configure PAM for 2FA + ansible.builtin.lineinfile: + path: /etc/pam.d/sshd + line: "auth required pam_google_authenticator.so nullok" + insertafter: "@include common-auth" + state: "{{ 'present' if (ENABLE_2FA | default(false)) else 'absent' }}" + become: true + +- name: Create 2FA enrollment script + ansible.builtin.copy: + dest: /usr/local/bin/force-2fa-enrollment + mode: '0755' + content: | + #!/bin/bash + # Script to force 2FA enrollment on first login + FLAG_FILE="$HOME/.force_2fa_enrollment" + if [ -f "$FLAG_FILE" ]; then + echo "===============================================================================" + echo "SECURITY REQUIREMENT: Two-Factor Authentication (2FA) Setup" + echo "===============================================================================" + echo "You must set up TOTP-based 2FA (Google Authenticator, Authy, Bitwarden, etc.)" + echo "to continue using this account." + echo "" + google-authenticator -t -d -f -r 3 -R 30 -w 3 + if [ $? -eq 0 ]; then + rm "$FLAG_FILE" + echo "" + echo "2FA setup successful! Please use your TOTP app for future logins." + echo "===============================================================================" + else + echo "" + echo "2FA setup failed. You will be prompted again at next login." + echo "===============================================================================" + exit 1 + fi + fi + when: ENABLE_2FA | default(false) + +- name: Add 2FA enrollment to shell profile + ansible.builtin.copy: + dest: /etc/profile.d/force-2fa-enrollment.sh + mode: '0644' + content: | + # Trigger 2FA enrollment if flag file exists + if [ -n "$SSH_TTY" ] && [ -f "$HOME/.force_2fa_enrollment" ]; then + /usr/local/bin/force-2fa-enrollment + fi + when: ENABLE_2FA | default(false) + +- name: Check if 2FA is already configured for users + ansible.builtin.stat: + path: "/home/{{ item.username }}/.google_authenticator" + loop: "{{ users_add_userlist | default([]) }}" + register: users_2fa_stat + when: ENABLE_2FA | default(false) + +- name: Force 2FA enrollment for existing users who haven't set it up + ansible.builtin.file: + path: "/home/{{ item.0.username }}/.force_2fa_enrollment" + state: touch + owner: "{{ item.0.username }}" + group: "{{ item.0.username }}" + mode: '0600' + loop: "{{ users_add_userlist | zip(users_2fa_stat.results) | list }}" + when: + - ENABLE_2FA | default(false) + - item.0.enable_2fa | default(true) + - not item.1.stat.exists + - not ansible_check_mode + label: "{{ item.0.username }}" + +- 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..9cff4c7 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 | bool else omit }}" # === SYSTEM SECURITY === disable_apport: true # Disable Ubuntu crash reporting From 8c020a154ec4933279504cb659dfcf326bb6becf Mon Sep 17 00:00:00 2001 From: jdaln <150942337+jdaln@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:49:54 +0100 Subject: [PATCH 2/8] caps fixes --- roles/users_add/handlers/main.yml | 4 ++-- roles/users_add/tasks/main.yml | 17 +++++++++-------- setup-playbook.yml | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/roles/users_add/handlers/main.yml b/roles/users_add/handlers/main.yml index 46a6766..1bb9c6c 100644 --- a/roles/users_add/handlers/main.yml +++ b/roles/users_add/handlers/main.yml @@ -13,6 +13,6 @@ group: "{{ item.username }}" mode: '0600' loop: "{{ users_add_userlist | default([]) }}" - when: - - ENABLE_2FA | default(false) + when: + - enable_2fa | default(false) - item.enable_2fa | default(true) diff --git a/roles/users_add/tasks/main.yml b/roles/users_add/tasks/main.yml index 0d43848..977dbd1 100644 --- a/roles/users_add/tasks/main.yml +++ b/roles/users_add/tasks/main.yml @@ -348,14 +348,14 @@ ansible.builtin.package: name: libpam-google-authenticator state: present - when: ENABLE_2FA | default(false) + when: enable_2fa | default(false) - name: Configure PAM for 2FA ansible.builtin.lineinfile: path: /etc/pam.d/sshd line: "auth required pam_google_authenticator.so nullok" insertafter: "@include common-auth" - state: "{{ 'present' if (ENABLE_2FA | default(false)) else 'absent' }}" + state: "{{ 'present' if (enable_2fa | default(false)) else 'absent' }}" become: true - name: Create 2FA enrollment script @@ -386,7 +386,7 @@ exit 1 fi fi - when: ENABLE_2FA | default(false) + when: enable_2fa | default(false) - name: Add 2FA enrollment to shell profile ansible.builtin.copy: @@ -397,14 +397,14 @@ if [ -n "$SSH_TTY" ] && [ -f "$HOME/.force_2fa_enrollment" ]; then /usr/local/bin/force-2fa-enrollment fi - when: ENABLE_2FA | default(false) + when: enable_2fa | default(false) - name: Check if 2FA is already configured for users ansible.builtin.stat: path: "/home/{{ item.username }}/.google_authenticator" loop: "{{ users_add_userlist | default([]) }}" register: users_2fa_stat - when: ENABLE_2FA | default(false) + when: enable_2fa | default(false) - name: Force 2FA enrollment for existing users who haven't set it up ansible.builtin.file: @@ -414,12 +414,13 @@ group: "{{ item.0.username }}" mode: '0600' loop: "{{ users_add_userlist | zip(users_2fa_stat.results) | list }}" + loop_control: + label: "{{ item.0.username }}" when: - - ENABLE_2FA | default(false) + - enable_2fa | default(false) - item.0.enable_2fa | default(true) - not item.1.stat.exists - not ansible_check_mode - label: "{{ item.0.username }}" - name: Remove 2FA enrollment from shell profile if disabled ansible.builtin.file: @@ -428,4 +429,4 @@ loop: - /usr/local/bin/force-2fa-enrollment - /etc/profile.d/force-2fa-enrollment.sh - when: not (ENABLE_2FA | default(false)) + when: not (enable_2fa | default(false)) diff --git a/setup-playbook.yml b/setup-playbook.yml index 9cff4c7..16ac7b5 100644 --- a/setup-playbook.yml +++ b/setup-playbook.yml @@ -93,7 +93,7 @@ become: true vars: users_add_userlist: "{{ SSH_USERLIST }}" - ENABLE_2FA: "{{ ENABLE_2FA }}" + enable_2fa: "{{ ENABLE_2FA }}" when: MANAGE_USERS is not defined or MANAGE_USERS | bool tags: [users, user-management] From 808dc4b04332ed3779219959e057fc66821295a8 Mon Sep 17 00:00:00 2001 From: jdaln <150942337+jdaln@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:49:52 +0100 Subject: [PATCH 3/8] prevent skipping 2FA --- roles/users_add/tasks/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/roles/users_add/tasks/main.yml b/roles/users_add/tasks/main.yml index 977dbd1..9a99b1c 100644 --- a/roles/users_add/tasks/main.yml +++ b/roles/users_add/tasks/main.yml @@ -353,7 +353,7 @@ - name: Configure PAM for 2FA ansible.builtin.lineinfile: path: /etc/pam.d/sshd - line: "auth required pam_google_authenticator.so nullok" + line: "auth required pam_google_authenticator.so" insertafter: "@include common-auth" state: "{{ 'present' if (enable_2fa | default(false)) else 'absent' }}" become: true @@ -373,7 +373,7 @@ echo "You must set up TOTP-based 2FA (Google Authenticator, Authy, Bitwarden, etc.)" echo "to continue using this account." echo "" - google-authenticator -t -d -f -r 3 -R 30 -w 3 + google-authenticator -t -d -f -r 3 -R 30 -w 3 -q if [ $? -eq 0 ]; then rm "$FLAG_FILE" echo "" From 606a43ed89ce96cf681670db2f7cde54cd04a593 Mon Sep 17 00:00:00 2001 From: jdaln <150942337+jdaln@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:14:20 +0100 Subject: [PATCH 4/8] fixes and deprec fixes --- README.md | 4 + ansible.cfg | 2 +- docs/security.md | 41 ++++++-- roles/users_add/handlers/main.yml | 11 --- roles/users_add/tasks/main.yml | 150 +++++++++++++++++++++--------- setup-playbook.yml | 2 +- 6 files changed, 142 insertions(+), 68 deletions(-) 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/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/handlers/main.yml b/roles/users_add/handlers/main.yml index 1bb9c6c..4270504 100644 --- a/roles/users_add/handlers/main.yml +++ b/roles/users_add/handlers/main.yml @@ -5,14 +5,3 @@ loop: "{{ users_add_userlist | default([]) }}" register: chage_output # This will register the output of the command to a variable changed_when: "'Password aging updated' in chage_output.stdout" -- name: Force 2FA enrollment on first login - ansible.builtin.file: - path: "/home/{{ item.username }}/.force_2fa_enrollment" - state: touch - owner: "{{ item.username }}" - group: "{{ item.username }}" - mode: '0600' - loop: "{{ users_add_userlist | default([]) }}" - when: - - enable_2fa | default(false) - - item.enable_2fa | default(true) diff --git a/roles/users_add/tasks/main.yml b/roles/users_add/tasks/main.yml index 9a99b1c..e33f1ef 100644 --- a/roles/users_add/tasks/main.yml +++ b/roles/users_add/tasks/main.yml @@ -12,12 +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 - - Force 2FA enrollment on first login no_log: true # Prevent password hashes from being logged - name: Add additional public keys @@ -134,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: @@ -302,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 @@ -316,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: @@ -325,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 @@ -350,55 +349,114 @@ state: present when: enable_2fa | default(false) -- name: Configure PAM for 2FA - ansible.builtin.lineinfile: - path: /etc/pam.d/sshd - line: "auth required pam_google_authenticator.so" - insertafter: "@include common-auth" - state: "{{ 'present' if (enable_2fa | default(false)) else 'absent' }}" - become: true +- 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: Create 2FA enrollment script +- name: Create 2FA enrollment script (pam_exec approach) ansible.builtin.copy: - dest: /usr/local/bin/force-2fa-enrollment + dest: /usr/local/sbin/ga-enroll-if-needed + owner: root + group: root mode: '0755' content: | - #!/bin/bash - # Script to force 2FA enrollment on first login - FLAG_FILE="$HOME/.force_2fa_enrollment" - if [ -f "$FLAG_FILE" ]; then - echo "===============================================================================" - echo "SECURITY REQUIREMENT: Two-Factor Authentication (2FA) Setup" - echo "===============================================================================" - echo "You must set up TOTP-based 2FA (Google Authenticator, Authy, Bitwarden, etc.)" - echo "to continue using this account." - echo "" - google-authenticator -t -d -f -r 3 -R 30 -w 3 -q - if [ $? -eq 0 ]; then - rm "$FLAG_FILE" - echo "" - echo "2FA setup successful! Please use your TOTP app for future logins." - echo "===============================================================================" - else - echo "" - echo "2FA setup failed. You will be prompted again at next login." - echo "===============================================================================" - exit 1 - fi + #!/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..., 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 utf8: force UTF8 QR code output (works without TTY detection) + # Pipe -1 to skip the code verification prompt (no TTY available in pam_exec) + # The -f flag ensures the file is written even when skipping verification + echo "-1" | google-authenticator -t -d -f -r 3 -R 30 -w 3 -e 5 -Q utf8 -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" + echo "" + echo "2FA setup successful! Please use your TOTP app for future logins." + echo "===============================================================================" + + exit 0 when: enable_2fa | default(false) -- name: Add 2FA enrollment to shell profile - ansible.builtin.copy: - dest: /etc/profile.d/force-2fa-enrollment.sh - mode: '0644' - content: | - # Trigger 2FA enrollment if flag file exists - if [ -n "$SSH_TTY" ] && [ -f "$HOME/.force_2fa_enrollment" ]; then - /usr/local/bin/force-2fa-enrollment - fi +- 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: | + # 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: Check if 2FA is already configured for users ansible.builtin.stat: path: "/home/{{ item.username }}/.google_authenticator" diff --git a/setup-playbook.yml b/setup-playbook.yml index 16ac7b5..63dba6d 100644 --- a/setup-playbook.yml +++ b/setup-playbook.yml @@ -241,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: From dc75d5ebb5ed99591ffe22998f65eb68f952a3fc Mon Sep 17 00:00:00 2001 From: jdaln <150942337+jdaln@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:36:18 +0100 Subject: [PATCH 5/8] QR fixed --- roles/users_add/tasks/main.yml | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/roles/users_add/tasks/main.yml b/roles/users_add/tasks/main.yml index e33f1ef..31e3fa3 100644 --- a/roles/users_add/tasks/main.yml +++ b/roles/users_add/tasks/main.yml @@ -358,6 +358,12 @@ 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 @@ -384,7 +390,7 @@ echo "===============================================================================" echo "SECURITY REQUIREMENT: Two-Factor Authentication (2FA) Setup" echo "===============================================================================" - echo "You must set up TOTP-based 2FA (Aegis, Google Authenticator, Authy, Bitwarden, etc..., etc.)" + 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 "" @@ -398,10 +404,9 @@ # -w 3: window of 3 codes (compensates for time skew) # -s: secret file location # -e 5: generate 5 emergency codes - # -Q utf8: force UTF8 QR code output (works without TTY detection) + # -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) - # The -f flag ensures the file is written even when skipping verification - echo "-1" | google-authenticator -t -d -f -r 3 -R 30 -w 3 -e 5 -Q utf8 -s "$secret" + 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 @@ -411,6 +416,21 @@ 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 "===============================================================================" From 6b872b3cf656437f055d86c9c7d6a0f5cd5745e3 Mon Sep 17 00:00:00 2001 From: jdaln <150942337+jdaln@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:43:28 +0100 Subject: [PATCH 6/8] removed individual 2fa due to new implementation logic --- roles/users_add/tasks/main.yml | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/roles/users_add/tasks/main.yml b/roles/users_add/tasks/main.yml index 31e3fa3..4ea34e2 100644 --- a/roles/users_add/tasks/main.yml +++ b/roles/users_add/tasks/main.yml @@ -477,29 +477,6 @@ path: /usr/local/bin/force-2fa-enrollment state: absent -- name: Check if 2FA is already configured for users - ansible.builtin.stat: - path: "/home/{{ item.username }}/.google_authenticator" - loop: "{{ users_add_userlist | default([]) }}" - register: users_2fa_stat - when: enable_2fa | default(false) - -- name: Force 2FA enrollment for existing users who haven't set it up - ansible.builtin.file: - path: "/home/{{ item.0.username }}/.force_2fa_enrollment" - state: touch - owner: "{{ item.0.username }}" - group: "{{ item.0.username }}" - mode: '0600' - loop: "{{ users_add_userlist | zip(users_2fa_stat.results) | list }}" - loop_control: - label: "{{ item.0.username }}" - when: - - enable_2fa | default(false) - - item.0.enable_2fa | default(true) - - not item.1.stat.exists - - not ansible_check_mode - - name: Remove 2FA enrollment from shell profile if disabled ansible.builtin.file: path: "{{ item }}" From 17eaab5ce11bd0a4cfa04428cba20b07ad2146cf Mon Sep 17 00:00:00 2001 From: jdaln <150942337+jdaln@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:49:40 +0100 Subject: [PATCH 7/8] last fixups --- roles/users_add/tasks/main.yml | 21 +++++++++++++++++++++ setup-playbook.yml | 2 +- testing/vars.yml | 1 + 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/roles/users_add/tasks/main.yml b/roles/users_add/tasks/main.yml index 4ea34e2..8d77936 100644 --- a/roles/users_add/tasks/main.yml +++ b/roles/users_add/tasks/main.yml @@ -343,6 +343,24 @@ # 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 @@ -444,6 +462,9 @@ 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 diff --git a/setup-playbook.yml b/setup-playbook.yml index 63dba6d..37ba114 100644 --- a/setup-playbook.yml +++ b/setup-playbook.yml @@ -178,7 +178,7 @@ 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 | bool else omit }}" + sshd_authentication_methods: "{{ 'publickey,keyboard-interactive:pam' if (ENABLE_2FA | default(false) | bool) else 'publickey' }}" # === SYSTEM SECURITY === disable_apport: true # Disable Ubuntu crash reporting diff --git a/testing/vars.yml b/testing/vars.yml index def6664..055e8fe 100644 --- a/testing/vars.yml +++ b/testing/vars.yml @@ -39,3 +39,4 @@ LOGIND_HARDENING: idleaction: lock idleactionsec: 15min removeipc: true +ENABLE_2FA: false \ No newline at end of file From f38994a6f6b72227995fd849cde471212e54dda8 Mon Sep 17 00:00:00 2001 From: jdaln <150942337+jdaln@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:42:50 +0100 Subject: [PATCH 8/8] lint --- cafe-variome-production-deployment-guide | 1 + roles/users_add/tasks/main.yml | 22 +++++++++++----------- testing/vars.yml | 2 +- 3 files changed, 13 insertions(+), 12 deletions(-) create mode 160000 cafe-variome-production-deployment-guide 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/roles/users_add/tasks/main.yml b/roles/users_add/tasks/main.yml index 8d77936..4f578eb 100644 --- a/roles/users_add/tasks/main.yml +++ b/roles/users_add/tasks/main.yml @@ -391,20 +391,20 @@ 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 "===============================================================================" @@ -412,34 +412,34 @@ 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 + # -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 "" @@ -452,7 +452,7 @@ echo "" echo "2FA setup successful! Please use your TOTP app for future logins." echo "===============================================================================" - + exit 0 when: enable_2fa | default(false) diff --git a/testing/vars.yml b/testing/vars.yml index 055e8fe..fe357c2 100644 --- a/testing/vars.yml +++ b/testing/vars.yml @@ -39,4 +39,4 @@ LOGIND_HARDENING: idleaction: lock idleactionsec: 15min removeipc: true -ENABLE_2FA: false \ No newline at end of file +ENABLE_2FA: false