はじめに

Google Authenticatorによる二段階認証を導入すると、ユーザごとにgoogle-authenticatorコマンドを実行してシークレットキーを作らないと、ログインできなくなる。
二段階認証を導入したので、設定をしていないユーザのログインができないのは当たり前だが、一部のユーザだけ通常のパスワードによるログインを許可したい場合は、二段階認証の設定に追加設定が必要となる。

Google Authenticatorの導入

Google Authenticatorのインストールと設定

作業ミス等により接続不能になることを想定して、作業用のターミナルとは別にssh接続をしておく。
まずはinstallを行う。

$ sudo yum install pam-devel
$ cd /usr/local/src/
$ git clone https://github.com/google/google-authenticator-libpam
$ cd google-authenticator-libpam
$ sudo yum install libtool
$ sudo ./bootstrap.sh
$ sudo ./configure
$ sudo make
$ sudo make install
$ ll /usr/local/lib/security
total 116
-rwxr-xr-x 1 root root   1024 Nov  7 12:05 pam_google_authenticator.la
-rwxr-xr-x 1 root root 112477 Nov  7 12:05 pam_google_authenticator.so
$ sudo cp -pi /usr/local/lib/security/pam_google_authenticator.so /lib64/security/

installが終わったらPAMでGoogle Authenticatorを使う設定を行う。設定ファイルの名前をgoogle-authとし、/etc/pam.dにおく。

$ cat << EOF | sudo tee /etc/pam.d/google-auth
#%PAM-1.0
auth required pam_env.so
auth sufficient pam_google_authenticator.so try_first_pass
auth requisite pam_succeed_if.so uid >= 500 quiet
auth required pam_deny.so
EOF

SSH時の認証でgoogle-authを読み込むように、/etc/pam.d/sshdを設定する。Google Authenticatorによる認証を行い、その次にパスワードによる認証を行い、合わせて二段階認証とするために、パスワード認証設定(auth include password-auth)の直前にGoogle Authenticatorの認証設定(auth substack google-auth)を書く。

$ cat /etc/pam.d/sshd
#%PAM-1.0
auth       required     pam_sepermit.so
auth       include      password-auth
account    required     pam_nologin.so
account    include      password-auth
password   include      password-auth
# pam_selinux.so close should be the first session rule
session    required     pam_selinux.so close
session    required     pam_loginuid.so
# pam_selinux.so open should only be followed by sessions to be executed in the user context
session    required     pam_selinux.so open env_params
session    required     pam_namespace.so
session    optional     pam_keyinit.so force revoke
session    include      password-auth

$ sudo sed -i '2 a \auth       substack     google-auth' /etc/pam.d/sshd

sshd_configでChallengeResponse認証を使うように設定する。

$ sudo sed -i 's/ChallengeResponseAuthentication no/ChallengeResponseAuthentication yes/' /etc/ssh/sshd_config
$ sudo service sshd restart

公開鍵認証は基本NGとするためにPubKeyAuthentication noを入れる。例外ユーザも定義できるが、rootは運用中に自分自身にSSHすることもあるだろうから、この例外に追加した方がいいだろう。
PermitRootLoginwithout-passwordにしたうえで、プライベートIPからアクセスされたときだけ鍵認証できるようにする。

$ sudo sed -i 's/PermitRootLogin .*/PermitRootLogin without-password/' /etc/ssh/sshd_config

$ cat <<EOF | sudo tee -a /etc/ssh/sshd_config
PubKeyAuthentication no
Match User root Address 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
  PubKeyAuthentication yes
EOF

$ sudo service sshd restart

ユーザの設定

二段階認証を使うgauthuserとパスワード認証を使うpauthuserがいるとする。
gauthuserについては、google-authenticatorコマンドでシークレットキーを作成する。

$ username=gauthuser
$ sudo su - $username -c "yes | google-authenticator"
$ sudo ls -l /home/$username/.google_authenticator
# secret keyの確認
$ sudo head -1 /home/$username/.google_authenticator
# emergency scratch codesの確認
$ sudo tail -5 /home/$username/.google_authenticator

gauthuserでログインするには、ChallengeResponse認証を指定してログイン試行する。するとOne-time Passwordを聞かれる。その後元々のユーザのパスワードが聞かれる。
pauthuserで通常のパスワードログインしようとすると、パスワードを入力してもエラーとなる。/var/log/secureには以下のようなログが記載される。

Nov  7 12:16:25 myserver sshd(pam_google_authenticator)[22250]: Failed to read "/home/pauthuser/.google_authenticator" for "pauthuser"
Nov  7 12:16:25 myserver sshd(pam_google_authenticator)[22250]: No secret configured for user pauthuser, asking for code anyway.
Nov  7 12:16:25 myserver sshd(pam_google_authenticator)[22250]: Invalid verification code for pauthuser
Nov  7 12:16:26 myserver sshd[22250]: Failed password for pauthuser from XXX.XXX.XXX.XXX port 1313 ssh2

特定のユーザで二段階認証を回避する設定

設定方法

pauthuserが二段階認証ではなく通常のパスワードでログインできるようにするために、/etc/pam.d/google-authの設定に一部追加する。
以下の設定部分で/home/$username/.google_authenticatorを読もうとして、ファイル読み込みに失敗するため、この設定に行く前に/etc/pam.d/google-authの処理を抜けたい。

auth sufficient pam_google_authenticator.so try_first_pass

PAMでは以下の通り処理の継続を制御できる。(参考

  • requisite:処理を継続するには、requiredモジュールが"success(成功)"を返さなければならない。このモジュールが失敗すると、PAMライブラリは"failure(失敗)"を返し、スタック内のほかのモジュールはそれ以上実行されない。
  • required:これも必須のモジュールであるが、失敗した場合、PAMのAPIは"failure"を返すものの、同じスタック内のモジュールの実行は継続される。
  • sufficient:このモジュールが成功すると、PAMライブラリは、その他のモジュールを実行することなく直ちに"success"を返す。
  • optional:このモジュールの成否に関わらず、スタック内のモジュールの実行は継続される。すべてのモジュールの実行が完了した時点でPAMライブラリの成否が確定していない場合、成功したoptionalモジュールが1つでもあれば承認が与えられる

そのため、ログインしようとしているユーザがpauthuserであればgoogle-auth内の処理はそれ以上行わない(つまりsufficient)という記述を、pam_google_authenticator.soの前にしてあげればよい。

pam_succeed_if.soはユーザIDやグループIDなどの条件を指定できる(参考)ため、このモジュールを使って、ユーザ名がpauthuserであるかどうかをチェックする。
pauthuser01, pauthuser02のようにパスワード認証を使うユーザが複数いて、pauthuserから始まるユーザをワイルドカードを使って一括して設定したいとする。

auth sufficient pam_succeed_if.so login =~ pauthuser*

これをpam_google_authenticator.soの直前の行に書くので、汎用的にするなら以下のように/etc/pam.d/google-authを書きたい。

$ users_not_2SV='pauthuser*'

$ cat << EOF | sudo tee /etc/pam.d/google-auth
#%PAM-1.0
auth required pam_env.so
auth sufficient pam_succeed_if.so login =~ $users_not_2SV
auth sufficient pam_google_authenticator.so try_first_pass
auth requisite pam_succeed_if.so uid >= 500 quiet
auth required pam_deny.so
EOF

/etc/pam.d/google-authを書き換えた後に、gauthuserとpauthuserでログインすると、それぞれ二段階認証と普通のパスワード認証でログインできる。

ログで見るPAMの挙動

gauthuserでログインした場合、/var/log/secureには以下のログが記載される。

Nov  7 15:36:27 myserver sshd[24303]: pam_succeed_if(sshd:auth): requirement "login =~ pauthuser*" not met by user "gauthuser"
Nov  7 15:36:33 myserver sshd(pam_google_authenticator)[24303]: Accepted google_authenticator for gauthuser
Nov  7 15:36:44 myserver sshd[24299]: Accepted keyboard-interactive/pam for gauthuser from XXX.XXX.XXX.XXX port 58291 ssh2
Nov  7 15:36:44 myserver sshd[24299]: pam_unix(sshd:session): session opened for user gauthuser by (uid=0)

gauthuserはpauthuser*の表記に合致しないためpam_succeed_ifは失敗し、次のpam_google_authenticatorに処理が移る。secret keyは設定済みなのでこの処理は成功し、/etc/pam.d/google-authの次の処理、つまりパスワード認証処理に移る。

pauthuserでログインした場合、/var/log/secureには以下のログが記載される。

Nov  7 15:33:54 myserver sshd[24251]: pam_succeed_if(sshd:auth): requirement "login =~ pauthuser*" was met by user "pauthuser"
Nov  7 15:33:54 myserver sshd[24251]: Accepted password for pauthuser from XXX.XXX.XXX.XXX port 8506 ssh2
Nov  7 15:33:54 myserver sshd[24251]: pam_unix(sshd:session): session opened for user pauthuser by (uid=0)

pauthuserはpauthuser*の表記に合致するためpam_succeed_ifは成功する。sufficientが指定されているので、成功するとその他のモジュールを実行することなく/etc/pam.d/google-auth自体が成功し、次の処理に移る。
つまり、One-time Passwordを聞かれることなく、次のパスワード認証に処理が移行する。

Ansibleでの実装

注意点

  • Ansibleの項は2018/4/23の追記となり、従来のCentOS 6ではなくCentOS 7で実行している。
  • CentOS 7のデフォルトに合わせて、/etc/pam.d/google-authの一部の値を変更している。
  • Ansibleのversionは2.2を使っている。
  • Ansibleでは変数名の英訳として二段階認証の Two Step Verification ではなく、二要素認証の 2 Factor Authentification を採用している。

実装のポイント

Ansibleで実装しようとしたとき、yes | google-authenticatorの部分をshellモジュールで実行すると、yes: standard output: Broken pipe\nyes: write errorというエラーがでてしまう。man google-authenticatorで対話式ではなくオプションで実行する方法を確認する必要がある。

Ansibleの機能(ansible_all_ipv4_addresses)を活かして、Ansibleでログインするのに使用するユーザ(my_ansible_user)も鍵認証できるようにするため、自身のプライベートIPとグローバルIPも追加するようにする。

実装

group_vars/all.yml

two_factor_auth:
  users_not_2fa: "pauthuser*"
  users:
    - username: gauthuser1
      uid: 90001
      password: YhNhThbK
    - username: gauthuser2
      uid: 90002
      password: s9Nteh6p

playbooks/two_factor_auth.yml

---
- hosts: bastion # 二要素認証を導入するのは踏み台サーバのみ
  become: yes
  gather_facts: no
  roles:
    - two_factor_auth
    - ssh

roles/two_factor_auth/tasks/main.yml

---
- block:
  - name: git clone google-authenticator
    tags: two_factor_auth
    git:
      repo: https://github.com/google/google-authenticator-libpam.git
      dest: /usr/local/src/google-authenticator-libpam
      update: no

  - name: check whether or not installed
    tags: two_factor_auth
    stat: path=/lib64/security/pam_google_authenticator.so
    register: f

  - block:
    - name: bootstrap
      tags: two_factor_auth
      shell: cd /usr/local/src/google-authenticator-libpam && ./bootstrap.sh

    - name: configure
      tags: two_factor_auth
      shell: cd /usr/local/src/google-authenticator-libpam && ./configure

    - name: make google-authenticator
      tags: two_factor_auth
      make:
        chdir: /usr/local/src/google-authenticator-libpam

    - name: make install google-authenticator
      tags: two_factor_auth
      make:
        chdir: /usr/local/src/google-authenticator-libpam
        target: install

    - name: copy pam_google_authenticator.so
      tags: two_factor_auth
      copy:
        src: /usr/local/lib/security/pam_google_authenticator.so
        dest: /lib64/security/pam_google_authenticator.so
        remote_src: yes
      ignore_errors: True
    when: not f.stat.exists

  - name: set google-auth pam
    tags: two_factor_auth
    template:
      src: google-auth.j2
      dest: /etc/pam.d/google-auth

  - name: set google-auth before password-auth in /etc/pam.d/sshd
    tags: two_factor_auth
    lineinfile:
      name: /etc/pam.d/sshd
      insertbefore: "^auth .*password-auth" 
      line: "auth       substack     google-auth"
      backup: yes

  - name: create users
    tags: two_factor_auth
    user:
      name: "{{ item.username }}"
      uid: "{{ item.uid }}"
      password: "{{ item.password | password_hash('sha512') }}"
      update_password: on_create
    register: user_add
    with_items: "{{ (two_factor_auth | default({})).users | default([]) }}"

  - name: chage -d 0
    tags: two_factor_auth
    shell: "chage -d 0 {{ item.item.username }}"
    when: item.createhome is defined and item.createhome == true
    with_items: "{{ user_add.results }}"

  - name: create .google_authenticator
    tags: two_factor_auth
    shell: |
      sudo -u {{ item.username }} /usr/local/bin/google-authenticator -tfd -w17 -r3 -R30 -C
      chown {{ item.username }}. /home/{{ item.username }}/.google_authenticator
    args:
      creates: /home/{{ item.username }}/.google_authenticator
    with_items: "{{ (two_factor_auth | default({})).users | default([]) }}"

  when: two_factor_auth is defined

roles/two_factor_auth/templates/google-auth.j2

#%PAM-1.0
auth required pam_env.so
{% if two_factor_auth.users_not_2fa is defined %}
auth sufficient pam_succeed_if.so login =~ {{ two_factor_auth.users_not_2fa }}
{% endif %}
auth sufficient pam_google_authenticator.so try_first_pass
auth requisite pam_succeed_if.so uid >= 1000 quiet_success
auth required pam_deny.so

roles/ssh/tasks/main.yml

---
- name: copy sshd_confing
  tags: openssh
  template:
    src: sshd_config.j2
    dest: /etc/ssh/sshd_config

- name: restart sshd
  service: name=sshd enabled=yes state=restarted

roles/ssh/templates/sshd_config.j2

Protocol 2
SyslogFacility AUTHPRIV
PasswordAuthentication yes
{% if 'bastion' in group_names and two_factor_auth is defined %}
ChallengeResponseAuthentication yes
{% else %}
ChallengeResponseAuthentication no
{% endif %}
UsePAM yes
AcceptEnv LANG LC_CTYPE LC_NUMERIC LC_TIME LC_COLLATE LC_MONETARY LC_MESSAGES
AcceptEnv LC_PAPER LC_NAME LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT
AcceptEnv LC_IDENTIFICATION LC_ALL LANGUAGE
AcceptEnv XMODIFIERS
X11Forwarding yes
PrintLastLog yes
Subsystem       sftp    /usr/libexec/openssh/sftp-server
PermitRootLogin without-password
{% if 'bastion' in group_names and two_factor_auth is defined %}
PubKeyAuthentication no
Match User root Address 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
  PubKeyAuthentication yes
Match User my_ansible_user Address {{ ansible_all_ipv4_addresses | join(',') }}
  PubKeyAuthentication yes
{% endif %}