Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion ansible.cfg
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions cafe-variome-production-deployment-guide
Submodule cafe-variome-production-deployment-guide added at fca2eb
41 changes: 32 additions & 9 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
180 changes: 174 additions & 6 deletions roles/users_add/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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))
11 changes: 10 additions & 1 deletion setup-playbook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]

Expand All @@ -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]

Expand All @@ -89,6 +93,7 @@
become: true
vars:
users_add_userlist: "{{ SSH_USERLIST }}"
enable_2fa: "{{ ENABLE_2FA }}"

Check warning on line 96 in setup-playbook.yml

View workflow job for this annotation

GitHub Actions / build

var-naming[no-role-prefix]

Variables names from within roles should use users_add_ as a prefix. (vars: enable_2fa)

Check warning on line 96 in setup-playbook.yml

View workflow job for this annotation

GitHub Actions / build

var-naming[no-role-prefix]

Variables names from within roles should use users_add_ as a prefix. (vars: enable_2fa)
when: MANAGE_USERS is not defined or MANAGE_USERS | bool
tags: [users, user-management]

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions testing/vars.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ LOGIND_HARDENING:
idleaction: lock
idleactionsec: 15min
removeipc: true
ENABLE_2FA: false
Loading