By

Admin用ページに簡単にmfa付き認証を設定する

はじめに

どんなシステムでも、認証やユーザ管理は難しいものです。顧客向けの機能では手を抜くわけにはいかないですが、内部向けの機能、管理者が使う機能では、なるべく簡単にすませたいものです。

しかし、管理者用のページは、万が一クラックされた場合の被害が深刻ですから、セキュリティ面では妥協はできません。

最近は、監視などの管理者、開発者向けのツールでも、設定画面をブラウザで開くようになっているものが多くなりました。また、VNCやSSHのようにCLIで使うツールをブラウザに移植したツールも増えています。

こういうものを活用すれば、管理が簡単になりますが、安全かつ最小限の手間でこういう管理者用の画面へのアクセス方法を確立するにはどうしたらいいかが、長年の悩みでした。

それが、最近リリースされたAWSの機能を使うことで、安全と簡単さを両立できることがわかりました。

それは、ALB と Congito という二つのマネージドサービスを組み合せて使う方法です。これについて説明したいと思います。

使用例

まず、イメージをつかんでもらうために、実際に使用するとどうなるかについて、使う側の視点で説明します。初期設定が完了した後の動作です。

これを使うには、Cognito UserPool というリソースにユーザを追加する必要があります。Cognito UserPool の主たる用途は、モバイルアプリなどで外部の一般ユーザのアカウントを大量に扱うものですが、10人程度の小さなチームでの内部的な管理にも便利に使えそうです。

その場合は、ユーザ自身のサインアップをできないようにして、管理者がユーザを登録するようにします。最初に、管理者が次のような画面でユーザを登録します。

そして、ユーザが管理ツールにアクセスしようとすると、次のように認証を促す画面が表示されます。これは、Cognitoのホステッドページによって表示された画面です。

ここに登録したメールアドレスとパスワードを入力すると

6桁の数字を入力する画面が表示されます。同時に、SMSによって、登録された電話番号にこのコードが送信されてくるので、これを入力すると認証が完了して、ALBの先にある管理ツールの画面に行くことができます。

このサンプルでは、gottyというツールを使っています。これは、なんとブラウザ内のターミナルで対象マシン上のシェルを操作できるものです。これに限らず、Webベースのツールであれば何でも、この認証を通るユーザだけに開放することができます。

前段にNginxなどをProxyとして運用すれば似たようなことが可能ですが、それと比較して、以下のようなメリットがあります。

  • 認証が AWS Cognito のホステッドページ内で行なわれるので、安全確実
  • ユーザの登録、削除を Cognito の Web Console の中で行なえる
  • SMSによるMFAを強制することができる
  • 外部のアカウント(Identity Provider)との連携など、拡張性が大きい

Congnitoの本来のターゲットは、一般向けモバイルアプリなので、本稿のような用途では機能が不足することはまずありません。また、大量の外部ユーザに対しての使用実績もあるので、サービス提供側のセキュリティ面でも安全と考えて間違いないと思います。

しかし、ALBとの連携はリリースされたばかりで、特にホステッドUIの部分では実装されてないところもあります。アプリを作成しないで、既存のUIだけで使おうとすると、できそうでできないことがたくさんありました。このエントリでは、MFAを使うという前提の中ですぐに使える一番簡単な方法を紹介したいと思います。

設定方法概要

この仕組みを構築するには、以下のような作業が必要になります。

  1. Cognito UserPool を作成する
  2. ALBと関連するリソースを作成する
  3. ALBにCongintoによる認証を設定する
  4. Cognito の WebConsole からユーザ登録をする

ここでは、1. 4. は Web Console から対話的に行なうことにして、2.と3. を CloudFormation と Ruby スクリプトで行ないます。

本来は、全部 Cloudformation で行ないたい所ですが、Cognito に関連するリソースは、現状、完全には CloudFormation でサポートされていないので、このような形にしました。おそらく、近い将来全面的にサポートされると思うのですが、現状では、一見できそうでなかなかできない罠が多かったです。

逆に、2.3. の部分も、スクリプトを見ながら同じことをWeb Console で設定することも簡単だと思います。

UserPool の作成

Aws の Web Console から、サービスとして、Cognitoを選択して、”Manage User Pools” -> “Create a user pool”とクリックすると、ウィザードが始まります。ほとんどのページは、何もしないで、”Next” を選択すれば問題ないですが、以下のページだけは、注意してください。

きちんと確認はしていませんが、これらの項目の中には、作成後に修正できないものもあるようです。最初にこのとおりに設定する必要があります。

Attributes の画面では、”Email address or phone numbe” と “Allow email addresses”を選択します。

Policies の画面では、”Only allow administrators to create users” を選択します。

Mfa and verifications では、”Required” を選択して、MFAの方法としては”SMS text messages”を選択します。

ここが一番罠な所で、”Time-based One-time Password”は選択できるのですが、実際には使えません。ユーザごとの mfa を enable する機能が、Hosted UI の中に実装されていないからです。APIでは既にサポートされているようですが、アプリを作成しないとそのAPIにアクセス手段がないようです。ですから、(現状では) ここは、”SMS text messages”にする必要があります。

また、ここで、このUserPoolにSMS送信を許可するための Role を作成する必要があります。同じページの下の方にある “Create role” のボタンをクリックしてから次のページに進んでください。

以下の項目は全部デフォルトのまま進めて、作成まで行なってください。

次に、App Client を作成する必要があります。”App Client” -> “Add an app client” をクリックして、適当な名前を入力して、”Create App Client”を作成してください。

この App Client には、以下の設定をする必要があります。

Callback URL には、 “https://albに与えるホスト名/oauth2/idpresponse” を設定します。

さらに、Hosted UI で使うドメインを設定する必要があります。これは、ユーザのログイン画面を表示する時にリダイレクトされるURLになります。従って、AWS全体でユニークな名前をつける必要があります。

なお、作成した UserPool を削除する時には、先にこのページに戻って、ここで確保したドメインを削除する必要があります。(“delete domain”をクリック)

これで、UserPool の作成が終わりました。次のステップのために、以下のものをメモしておきます。

  • UserPool の Arn (“General Settings”の”Pool Arn”)
  • App Client id
  • App Client で確保したドメイン

ALB の作成

次に、ALBと関連するリソースを作成します。ついでに、検証用のEC2インスタンスを作成し、それをターゲットグループに登録します。この部分は CloudFormation のテンプレートを用意しました。

ALBの作成には、別々のAZに属する subnet が二つ必要になります。このテンプレートでは、VPCとsubnet二つのIDをパラメータとして与えるようにしています。

また、このEC2インスタンスには、検証用の管理ツールとして、gotty をインストールしています。これについては、後で説明します。

AWSTemplateFormatVersion: 2010-09-09
Description: Demo for connecting admin tools by authetication with mfa
Parameters:
  VpcId:
    Description: VpcId
    Type: String
    Default: ""
  Subnet1:
    Description: Subnet1
    Type: String
    Default: ""
  Subnet2:
    Description: Subnet2
    Type: String
    Default: ""

Resources:
  # ALB 用の Security Group
  ALBSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security Group for ALB
      VpcId: !Ref VpcId
      SecurityGroupIngress:
        - IpProtocol: tcp # メインのListener用ポート メニュー表示のみに使う
          CidrIp: 0.0.0.0/0
          FromPort: 443
          ToPort: 443
        - IpProtocol: tcp # ツールの Proxy 用 Listener用ポート
          CidrIp: 0.0.0.0/0
          FromPort: 15001
          ToPort: 15001

  # EC2 インスタンス用の Security Group
  InstanceSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security Group for Instance
      VpcId: !Ref VpcId
      SecurityGroupIngress:
        - IpProtocol: tcp # for debugging purpose only 
          FromPort: 22
          ToPort: 22
          CidrIp: 0.0.0.0/0
        - IpProtocol: tcp # gotty 
          FromPort: 18080
          ToPort: 18080
          SourceSecurityGroupId: !Ref ALBSG

  MyInstance1:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: t2.nano
      ImageId: ami-08847abae18baa040 # Amazon Linux 2 AMI
      SecurityGroupIds:
        - !Ref InstanceSG
      SubnetId: !Ref Subnet1
      KeyName: xxxxx
      UserData: #
        Fn::Base64:
          |
          #cloud-config
          ---
          write_files:
            - path: /etc/systemd/system/gotty.service
              owner: root:root
              permissions: '0644'
              content: |
                [Unit]
                Description=gotty
                After=network.target

                [Service]
                Type=simple
                ExecStart=/bin/sh -c 'exec /usr/local/bin/gotty -w -p 18080 /bin/bash > /var/log/gotty.log'
                Restart = always

                [Install]
                WantedBy = multi-user.target
          runcmd:
            - set -ex
            - cd /root && wget https://github.com/yudai/gotty/releases/download/v2.0.0-alpha.3/gotty_2.0.0-alpha.3_linux_amd64.tar.gz && tar zxvf gotty_2.0.0-alpha.3_linux_amd64.tar.gz && mv gotty /usr/local/bin
            - systemctl start gotty

  MyLoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: "mfatest"
      Scheme: "internet-facing"
      Subnets:
        - !Ref Subnet1
        - !Ref Subnet2
      SecurityGroups:
        - !Ref ALBSG

  MyTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: "mfatest-targetgroup"
      VpcId: !Ref VpcId
      # HealthCheck
      HealthCheckPath: '/'
      HealthCheckPort: 18080
      HealthCheckProtocol: HTTP
      Matcher:
        HttpCode: '200'
      Port: '18080'
      Protocol: HTTP
      Targets:
        - Id: !Ref MyInstance1
          Port: 18080

Outputs:
  TargetGroup:
    Value: !Ref MyTargetGroup
    Export:
      Name: "targetgroup"
  ALB:
    Value: !Ref MyLoadBalancer 
    Export:
      Name: "alb"

ALB に Cognito の認証を設定する

次に、ALB に Listener と ListenerRule を作成し、Cognito の認証を設定します。

この部分は、CloudFormation で行なう予定でしたが、現状、CloudFormation の Action として、 type: "authenticate-cognito"を受け付けないようなので、Ruby のスクリプトで作成しました。

スクリプトの最初の方に、今まで作成したリソースのIDなどを定数として設定していますので、実際に使用される方は、これらを書き換えてから使用してください。

#!/usr/bin/env ruby

require 'aws-sdk-elasticloadbalancingv2'

ENV['AWS_REGION'] ||= 'ap-northeast-1'

####### you need to update these constants before executing this script #####
ALB_NAME="xxxxxxx"
ALB_HOST_NAME="xxx.xxx.com"
ALB_SUBNETS = %w(
  subnet-xxxxxxxx
  subnet-xxxxxxxx
)

ALB_SG = %w(
  sg-xxxxxxxx
)

SSL_CERTIFICATE='arn:aws:acm:ap-northeast-1:99999999:certificate/xxxxxxxxxxxxxxxx'

USER_POOL_ARN = 'arn:aws:cognito-idp:ap-northeast-1:9999999999:userpool/ap-northeast-1_xxxxxxxxxxxxxx'
USER_POOL_CLIENT_ID = 'xxxxxxxxxxxxx'
USER_POOL_DOMAIN = 'xxxxx-xxxxxx.auth.ap-northeast-1.amazoncognito.com'

TARGETS = [
  {
    name: 'gotty',
    path: '/',
    port: 15001,
    target_group_arn: 'arn:aws:elasticloadbalancing:ap-northeast-1:9999999999:targetgroup/xxxxxxxxxxxxxxxxx'
  },
]
######################################################################################

# default listener で表示するメニュー
def menu_html(alb_host_name)
  items = TARGETS.map do |t| 
    "<li><a href='https://#{alb_host_name}:#{t[:port]}#{t[:path]}' target="_blank">#{t[:name]}</a></li>"
  end.join("\n")

  <<-EOF
    <html>
    <body>
    <ul>
      #{items}
    </ul>
    </body>
    </html>
   EOF
end


def get_alb(client)
  r = client.describe_load_balancers(names: [ALB_NAME])
  r.load_balancers.first
end

def delete_current_listner(client, alb)
  r = client.describe_listeners(load_balancer_arn: alb.load_balancer_arn)
  r.listeners.each do |l| 
    client.delete_listener(listener_arn:l.listener_arn)
  end
end

# Congnito で認証するアクション
def autheticate_cognito
  {
    type: "authenticate-cognito",
    order: 1,
    authenticate_cognito_config: {
      user_pool_arn: USER_POOL_ARN,
      user_pool_client_id: USER_POOL_CLIENT_ID,
      user_pool_domain: USER_POOL_DOMAIN,
      scope: "openid",
      on_unauthenticated_request: "authenticate", # accepts deny, allow, authenticate
    }
  }
end

def create_main_listener(client, alb)
  alb_host_name = ALB_HOST_NAME
  # or alb_host_name = alb.dns_name
  client.create_listener(
    load_balancer_arn: alb.load_balancer_arn,
    port: 443,
    protocol: 'HTTPS',
    certificates: [
      {
        certificate_arn: SSL_CERTIFICATE,
      },
    ],
    default_actions: [
      autheticate_cognito,
      {
        type: "fixed-response",
        order: 2,
        fixed_response_config: {
          message_body: menu_html(alb_host_name),
          status_code: "200",
          content_type: "text/html"
        },
      }
    ]
  )
end

def create_target_listener(client, alb, t)
  client.create_listener(
    load_balancer_arn: alb.load_balancer_arn,
    port: t[:port],
    protocol: 'HTTPS',
    certificates: [
      {
        certificate_arn: SSL_CERTIFICATE,
      },
    ],
    default_actions: [
      autheticate_cognito,
      {
        type: "forward",
        order: 2,
        target_group_arn: t[:target_group_arn]
      }
    ]
  )
end

def main
  client = Aws::ElasticLoadBalancingV2::Client.new
  alb = get_alb(client)
  delete_current_listner(client, alb)
  create_main_listener(client, alb)
  TARGETS.each do |t| 
    create_target_listener(client, alb, t)
  end
end

main

このテンプレートでは、Port 443のDefault Listenerには、(これも最近追加された) fixed-response を利用して、メニューを表示するようにしています。

実際に使用する管理ツールは、それとは別のListenerに割り当てるようにしています。このあたりの振り分けの方法は、使用する管理ツールの数や対象のホストによっていろいろなパターンが考えられると思います。

ただ、注意すべき点としては、WebSocket を使うツールは、wss: へのリクエストのパスが “/” になってないとエラーになる場合が多いことです。従って、この例のようにツールごとにポートとListenerとTargetGroupを分ける方が(無駄使いのようですが)適用範囲が広いような気がします。

これらのメニューとListener については、TARGETS という定数で設定しています。

TARGETS = [
  {
    name: 'gotty',
    path: '/',
    port: 15001,
    target_group_arn: 'arn:aws:elasticloadbalancing:ap-northeast-1:9999999999:targetgroup/xxxxxxxxxxxxxxxxx'
  },
]

ここの port は、ALB -> EC2 Instance で使用するポートになります。従って、ここにポートを追加する場合は、CF テンプレートの InstanceSG で同じポートに許可を与える必要があります。

ユーザを登録する

UserPool へのユーザ登録は、最初の例で示したように、管理者が Web Console から行ないます。

ただし、登録時には一点注意点があります。初回ログインの時にはMFAが使われません。

ユーザ登録時に設定するパスワードは、初回のログインでのみ使われて、ログインするとすぐに正規のパスワード設定を促す画面が表示されます。この動作は問題ないですが、問題は、そこで正規のパスワードを設定すると、すぐにログイン済の状態になって、ALBの後ろにあるツールにアクセスできる状態になってしまうことです。

ですから、ユーザ登録時に送信される招待メールを盗み見された場合に、不正にログインされてしまう可能性があります。

ということは、ユーザ登録する時には、登録したらすぐにそのユーザに初回ログイン→正規パスワード設定の流れをやってもらった方がいいと思います。最初にこれさえやっておけば、次回以降はSMSを使ったMFAによるログインになるので問題ありません。

gotty と noVNC について

この検証用のテンプレートでは、サンプルとして、gottyというツールを使っています。これは、ブラウザでアクセスするとJavaScriptで実装されたターミナルの画面がブラウザ内に開いて、そこで、指定したCUIのツールを実行するというものです。

似たようなツールとして、butterflyというのもあります。

この例では、bash を root の権限で実行するという、非常に危険なことをやっています。これは、認証関連に少しでもスキがあればとんでもないことになりますので注意してください。

もう少し現実的な例をあげると、gotty のコマンドラインで指定するツールをシェルでなくて管理用のCUIツールにすることもできます。たとえば、次のような設定にすると、glancesが起動します。(glances は、環境変数のTERMを設定しないと動きませんでした)

ExecStart=/bin/sh -c 'exec /usr/local/bin/gotty -w -p 18080 env TERM=xterm /usr/bin/glances'

一見、ふつうのターミナルのようですが、これもブラウザの中で動いています。

さらに、noVNCを使うと、EC2 インスタンスで GUI のデスクトップを起動して、それをブラウザから操作するということもできます。

参考までに、これを試した時の UserData を添付しておきます。これを使うと、Amazon Linux2 を GUI のデスクトップから使うことができます。

      UserData: #
        Fn::Base64:
          |
          #cloud-config
          ---
          packages:
            - git
            - tigervnc-server
          write_files:
            - path: /etc/systemd/system/vncserver@:1.service
              owner: root:root
              permissions: '0644'
              content: |
                [Unit]
                Description=Remote desktop service (VNC)
                After=syslog.target network.target

                [Service]
                Type=forking

                # Clean any existing files in /tmp/.X11-unix environment
                ExecStartPre=/bin/sh -c '/usr/bin/vncserver -kill %i > /dev/null 2>&1 || :'
                ExecStart=/usr/sbin/runuser -l ec2-user -c "/usr/bin/vncserver -geometry 1280x800 %i"
                PIDFile=/home/ec2-user/.vnc/%H%i.pid
                ExecStop=/bin/sh -c '/usr/bin/vncserver -kill %i > /dev/null 2>&1 || :'

                [Install]
                WantedBy=multi-user.target
            - path: /etc/systemd/system/gotty.service
              owner: root:root
              permissions: '0644'
              content: |
                [Unit]
                Description=gotty
                After=network.target

                [Service]
                Type=simple
                ExecStart=/bin/sh -c 'exec /usr/local/bin/gotty -w -p 18080 /bin/bash > /var/log/gotty.log'
                Restart = always

                [Install]
                WantedBy = multi-user.target
          runcmd:
            - set -ex
            - cd /root && wget https://github.com/yudai/gotty/releases/download/v2.0.0-alpha.3/gotty_2.0.0-alpha.3_linux_amd64.tar.gz && tar zxvf gotty_2.0.0-alpha.3_linux_amd64.tar.gz && mv gotty /usr/local/bin
            - systemctl start gotty
            - yum install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
            - yum groupinstall -y "MATE Desktop" --skip-broken
            - echo mate-session > /etc/X11/xinit/Xclients 
            - chmod +x /etc/X11/xinit/Xclients 
            - mkdir -p /home/ec2-user/.vnc && echo "123456" | vncpasswd -f > /home/ec2-user/.vnc/passwd && chown -R ec2-user /home/ec2-user/.vnc && chmod 600 /home/ec2-user/.vnc/passwd
            - systemctl start vncserver@:1
            - curl "https://bootstrap.pypa.io/get-pip.py" -o "/tmp/get-pip.py"
            - python /tmp/get-pip.py
            - pip install websockify
            - cd /root && git clone https://github.com/novnc/noVNC
            - /bin/websockify -D --web /root/noVNC/ 6080 localhost:5901 --heartbeat=60

料金について

UserPool の使用料金については、ユーザ数単位の従量課金で、50000ユーザまでは無料枠になっています。

ALBについては、LoadBalancer一台あたりの料金が、$17/month 程度かかります。他にトラフィックに応じた従量課金がありますが、今回のような用途では無視できる範囲でしょう。

この管理用のALBは、VPC内でひとつあれば、Listener や TargetGroup を複数登録することで共有できます。つまり、VPCあたり月2000円と考えておけばいいと思います。

認証だけのサービスと考えるとちょっと高めな気もしますが、これを一回設定しておけば、Admin 用ページの管理についてほぼ悩むことがなくなると考えれば、まあ納得できる範囲だと思います。

ALB経由にすることで、AWS Shield (マネージド型の DDoS 保護) | AWSや、AWS WAF(Web アプリケーションファイアーウォール)|AWSも使えるので、セキュリティ向上のためのコストと考えれば、コストパフォーマンスは悪くないような気がします。

まとめ

Congnito と ALB を使って、自営サーバなしで認証を行なう方法を説明しました。

この方法は、Admin用のページのPathを、ListenerRule の path-patternで限定できる場合なら、同じように使えます。従って、ほとんどの場合、Admin専用ページは、この方法を使って認証方法やAdminユーザの管理方法を統一できると思います。

また、Cognito は、まだまだ進化の途中なので、今後、プログラミング無しで使える機能も増えていくと思われます。

私としては、Google Autheticator などと同様の、タイムベースのワンタイムパスワードを使えるようになることや、一つのUserPoolの中をグループで分けて、特定のグループだけに特定のALBへのアクセスを許す、といったことを期待しています。(後者は、イベントトリガーで Lambda のファンクションを呼び出す機能があるので、現在でもそれらを使えばできるかもしれません)

一緒にユニークな決済サービスを作ってくれる Rails エンジニアを募集中です!
多国籍なメンバーと一緒に仕事をしてみませんか?詳細はこちらのリンクです:D