タケユー・ウェブ日報

Ruby on Rails や Flutter といったWeb・モバイルアプリ技術を武器にお客様のビジネス立ち上げを支援する、タケユー・ウェブ株式会社の技術ブログです。

rspec で Firebase ID Token を stub

class SessionsController < ApplicationController
  def create
    id_token = params.required(:id_token)
    user = User.from_firebase(id_token)
    # (snip)
  end
end

class User < ApplicationRecord

  CIRTIFICATE_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'
  EXP_LEEWAY = 30.seconds

  def self.from_firebase(id_token)
    firebase_project_id = Rails.application.credentials.dig(:firebase, :project_id)
    valid_iss = "https://securetoken.google.com/#{firebase_project_id}"

    # https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja
    a, decoded_token_header = JWT.decode(id_token, nil, false)

    certificate = Rails.cache.fetch('firebase_securetoken_cirtificate', expires: 1.hour) do
      uri = URI.parse(CIRTIFICATE_URL)
      JSON.parse(Net::HTTP.get_response(uri).body).fetch(decoded_token_header["kid"])
    end

    public_key = OpenSSL::X509::Certificate.new(certificate).public_key
    decoded_token_payload, _ = JWT.decode(
      id_token,
      public_key,
      true,
      exp_leeway: EXP_LEEWAY,   # 有効期限の検証をするが、ゆるめに。 EXP_LEEWAY 秒は大目に見る。
      verify_iat: true,         # 発行時の検証をする
      aud: firebase_project_id,
      verify_aud: true,         # 対象の検証をする
      iss: valid_iss,
      verify_iss: true,         # 発行元の検証をする
      verify_sub: true,         # 件名の存在を検証する
      algorithm: decoded_token_header["alg"]
    )

    Rails.logger.debug("decoded_token_payload: #{decoded_token_payload.inspect}")

    # decoded_token_payload = {
    #   "name"=>"Takeuchi Yuichi",
    #   "picture"=>"https://lh3.googleusercontent.com/a-/AAuE7mAZU7Rh7lIFStzfWGe3tC24qDIX4UIoEWR8426flA",
    #   "iss"=>"https://securetoken.google.com/rails-firebase-sample",
    #   "aud"=>"rails-firebase-sample",
    #   "auth_time"=>1580712233,
    #   "user_id"=>"Qgk3sd1HgoPLVbSy8uXAWnRmWmx1",
    #   "sub"=>"Qgk3sd1HgoPLVbSy8uXAWnRmWmx1",
    #   "iat"=>1580712233,
    #   "exp"=>1580715833,
    #   "email"=>"takeyuweb@gmail.com",
    #   "email_verified"=>true,
    #   "firebase"=>{
    #     "identities"=>{
    #       "google.com"=>["100008179958237311525"],
    #       "email"=>["takeyuweb@gmail.com"]
    #     },
    #     "sign_in_provider"=>"google.com"
    #   }
    # }

    where(uid: decoded_token_payload.fetch("sub")).first_or_create
  end
end
require 'rails_helper'

RSpec.describe "/session", type: :request do
  describe 'create session' do
    context "with valid id_token" do
      it "returns Created" do
        stub_id_token do |id_token|
          post "/session", params: { id_token: id_token }
          expect(response).to have_http_status(:created)
        end
      end
    end

    context "with invalid id_token" do
      it "returns Unauthorized " do
        stub_id_token("exp" => 0) do |id_token|
          post "/session", params: { id_token: id_token }
          expect(response).to have_http_status(:unauthorized)
        end
      end
  end
end
# spec/support/firebase.rb

module FirebaseIdTokenGenerator
  def stub_id_token(override = {}, &block)
    unless defined?(@pkey)
      @pkey, @cert = generate_key_pair
      @kid = 'thekeyid'

      certificates = {
        'dummy' => generate_key_pair[1].to_pem,
        @kid => @cert.to_pem
      }
      WebMock.stub_request(:get, User::CIRTIFICATE_URL).to_return(status: 200, body: certificates.to_json)
    end

    block.call(get_id_token(generate_payload(override)))
  end

  def get_id_token(payload)
    JWT.encode(payload, @pkey, 'RS256', { kid: @kid, typ: 'JWT' })
  end

  def generate_payload(override = {})
    # https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja
    firebase_project_id = Rails.application.credentials.dig(:firebase, :project_id)
    {
      "name"=>"Takeuchi Yuichi",
      "picture"=>"https://test.host/picture.jpeg",
      "iss" => "https://securetoken.google.com/#{firebase_project_id}",
      "aud" => firebase_project_id,
      "auth_time"=> Time.current.to_i,
      "user_id"=>"theuserid",
      "sub"=>"theuserid",
      "iat"=> 1.hour.ago.to_i,
      "exp"=> 1.hour.from_now.to_i,
      "email"=>"yuichi.takeuchi@takeyuweb.co.jp",
      "email_verified"=>true,
      "firebase"=>{
        "identities"=>{
          "google.com"=>["000000000000000000000"],
          "email"=>["yuichi.takeuchi@takeyuweb.co.jp"]
        },
        "sign_in_provider"=>"google.com"
      }
    }.deep_merge(override)
  end

  def generate_key_pair
    ca_passphrase = SecureRandom.alphanumeric
    digest = OpenSSL::Digest::SHA1.new

    issu = OpenSSL::X509::Name.new
    issu.add_entry('C' , 'JP')
    issu.add_entry('ST', 'Saitama')
    issu.add_entry('DC', 'Omiya-ku')
    issu.add_entry('O' , 'TakeyuWeb, Inc.')
    issu.add_entry('CN', 'MexiCasita Test CA')

    issu_rsa = OpenSSL::PKey::RSA.generate(2048)

    issu_cer = OpenSSL::X509::Certificate.new
    issu_cer.not_before = Time.current
    issu_cer.not_after  = 10.years.from_now
    issu_cer.public_key = issu_rsa.public_key
    issu_cer.serial  = 1
    issu_cer.issuer  = issu
    issu_cer.subject = issu
    ex = OpenSSL::X509::Extension.new('basicConstraints', OpenSSL::ASN1.Sequence([OpenSSL::ASN1::Boolean(true)]))
    issu_cer.add_extension(ex)
    issu_cer.sign(issu_rsa, digest)

    return [issu_rsa, issu_cer]
  end
end

RSpec.configure do |config|
  config.include FirebaseIdTokenGenerator

  config.after(:all, type: :request) do
    remove_instance_variable(:@pkey) if defined?(@pkey)
  end
end

rspec で ActionMailer の deliver_later のジョブはすぐに処理する

ActiveJob へのキューインの spec で示したように ActiveJob::Base.queue_adapter = :test を使うと、 perform_later のジョブはキューに溜まるようになる。

しかし、 ActionMailer の deliver_later で非同期送信を使っている場合にまでキューインされてしまうと、 「メールを送信する」ことのテストを書きづらいことがある。

まとめ

  • ActiveJob::TestHelper に含まれる perform_enqueued_jobs を使うと、ブロック内ではエンキューされたジョブはすぐに実行される
  • only: ActionMailer::MailDeliveryJob と指定すれば、メール送信のみすぐに実行できる

サンプルコード

include ActiveJob::TestHelper

it "It sends the server key" do
  expect {
    perform_enqueued_jobs(only: ActionMailer::MailDeliveryJob) { NotifyJob.perform_now(user) }
  }.to change(ActionMailer::Base.deliveries, :count).by(1)
  
  mail = ActionMailer::Base.deliveries.last
  expect(mail.attachments.map(&:filename)).to match_array(["key.pem"])
end

ActiveJob::TestHelper

api.rubyonrails.org

ActiveJob へのキューインの spec

まとめ

  • ActiveJob::Base.queue_adapter = :test を使うと、 ActiveJob::Base.queue_adapter.enqueued_jobs にジョブが入る
  • have_enqueued_job マッチャーでジョブが入っていることを expect する

サンプルコード

it 'enqueues PaymentIntentSuccessedJob' do
  ActiveJob::Base.queue_adapter = :test

  event = StripeMock.mock_webhook_event('payment_intent.succeeded', customer: customer.id)
  expect {
    post "/webhook", params: event.to_h, as: :json
  }.to have_enqueued_job(PaymentIntentSuccessedJob).with(event.id)
end

aws-sdk-ruby で AWS CodeCommit の SSH キーを登録する

まとめ

  • SSHキーペア生成は OpenSSL::PKey::RSA#generate を使う
  • アップロードは Aws::IAM::Client#upload_ssh_public_key を使う

事の起こり

開発中のWebシステムでは、必要に応じてAWS上のリソースを生成しています。 CodeCommitへのコミットに使用するIAMユーザを作成、Git ssh接続用のキーペアを生成し、秘密鍵を使って接続できるようにする一連の作業を自動化することにしました。

実施内容

SSHキーペア生成

必要な鍵の仕様について、ドキュメントによれば

The public key must be encoded in ssh-rsa format or PEM format. The minimum bit-length of the public key is 2048 bits, and the maximum length is 16384 bits.

ということで、

  • 最低2048ビット
  • OpenSSH形式かPEM形式の鍵

を作ればよいようです。

docs.aws.amazon.com

PEM形式であればRuby標準のライブラリだけで生成できます。

docs.ruby-lang.org

rsa = OpenSSL::PKey::RSA.generate(2048)
public_key_body = rsa.public_key.to_pem
private_key_body = rsa.to_pem

なお、OpenSSH形式の場合は net-ssh gem が必要です。

stackoverflow.com

公開鍵アップロード

CodeCommitで使うものですが、IAMのSDKを使います。

iam = Aws::IAM::Client.new(region: aws_region, credentials: credentials)
ssh_public_key = iam.upload_ssh_public_key(
  user_name: user_name,  # IAM ユーザー名
  ssh_public_key_body: public_key_body,
).ssh_public_key

ssh_public_key_id = ssh_public_key.ssh_public_key_id # => "APKXXXXXXXXXXXXXXXXX"
File.open("#{ssh_public_key_id}.pem", "wb") do |file|
  file.write private_key_body
end
鍵を使う

sshユーザー名は Aws::IAM::Client#upload_ssh_public_key の応答に含まれる ssh_public_key_id を使います。

こんな .ssh/config を使うとよいでしょう。

Host user_name.codecommit
      HostName git-codecommit.ap-northeast-1.amazonaws.com
      User APKXXXXXXXXXXXXXXXXX
      Port 22
      IdentityFile ~/.ssh/APKXXXXXXXXXXXXXXXXX.pem
      IdentitiesOnly yes
$ git clone ssh://user_name.codecommit/v1/repos/repository-name

WSLのバックアップとインポート

まとめ

  • wsl --exportwsl --import
  • インポートした後はデフォルトのユーザーが root になってしまうので、設定してやる必要がある
  • .bat を書けばバックアップをとってからシャットダウン、もできる

forest.watch.impress.co.jp

事の起こり

  • WSL2をゴリゴリ仕事で使っている
  • 何かあったとき戻せるようにしたい
  • ツール類の再インストール作業などなるべくしたくない
  • プロビジョニングツールはツールの更新で動かなくなったりしてつらい
  • スナップショットとりたい

バッチファイルを書いた

wsl-backup.bat

@echo off

echo %date%
echo %time%

set yyyy=%date:~0,4%
set mm=%date:~5,2%
set dd=%date:~8,2%

set time2=%time: =0%

set hh=%time2:~0,2%
set mn=%time2:~3,2%
set ss=%time2:~6,2%

set timestamp=%yyyy%-%mm%-%dd%-%hh%-%mn%-%ss%
wsl --export Ubuntu D:\backups\wsl\Ubuntu_%timestamp%.tar

shutdown.bat

@echo off

call D:\wsl-backup.bat

shutdown /s /t 30 /c "Shut down after 30 seconds."

仕事終わりに shutdown.bat を「管理者で実行」

復元方法

WSL2のセットアップを終えた環境で

wsl --import <NAME> <PATH> <FILE>

wsl --import Ubuntu "C:\wsl\Ubuntu" D:\backups\wsl\Ubuntu_2020-05-30-11-19-12.tar

デフォルトのユーザーを設定

そのままだと root になるので、デフォルトのユーザーを指定します。

C:\Users\yuichi> wsl
Last login: Fri Apr 17 21:35:43 JST 2020 on pts/5
Welcome to Ubuntu 18.04.4 LTS (GNU/Linux 5.4.72-microsoft-standard-WSL2 x86_64)

root@PANDA:~#
Function WSL-SetDefaultUser ($distro, $user) { Get-ItemProperty Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Lxss\*\ DistributionName | Where-Object -Property DistributionName -eq $distro | Set-ItemProperty -Name DefaultUid -Value ((wsl -d $distro -u $user -e id -u) | Out-String); };
WSL-SetDefaultUser <DistroName> <UserName>

WSL-SetDefaultUser Ubuntu takeyuweb

github.com

AWS SDK for Ruby で EC2 ImageBuilder のパイプラインを作成する

まとめ

事の起こり

  • EC2 ImageBuilder でゴールデンAMIを作成している
  • EC2 ImageBuilder のコンポーネントを変更する際、それを使うようにパイプラインを更新するには、コンポーネント、レシピ、パイプラインとそれぞれ作り直す必要があり、面倒
  • AWSマネジメントコンソール上からだと設定できないオプション(たとえばイメージ作成に使用するEC2インスタンスのEBSボリュームサイズ)がある
  • CloudFormationでは作成できない

AWS SDKを用いてコード化し、管理可能かつ実行可能にすることで、作業を効率化したくなりました。

サンプルコード

CloudFormation

S3バケットやEC2インスタンスプロファイルなどはCloudFormationで作成していたのでそれを使いました。 スタックのOutputsでRubyスクリプトに渡します。

ポイントはEC2インスタンスに割り当てるロールです。

Mappings:
  SubnetConfig:
    VPC:
      CIDR: "10.0.0.0/16"
    Private:
      CIDR: "10.0.1.0/24"
    Public:
      CIDR: "10.0.2.0/24"
Resources:
  ImageBuilderRole:
    Type: "AWS::IAM::Role"
    DeletionPolicy: Retain
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - "sts:AssumeRole"
      Path: /
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
        - "arn:aws:iam::aws:policy/EC2InstanceProfileForImageBuilder"
      Policies:
        - PolicyName: allow-put-log
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action:
                  - "s3:*"
                Resource:
                  - !GetAtt BuildLogBucket.Arn
                  - !Join ["/", [!GetAtt BuildLogBucket.Arn, "*"]]

  EIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc

  VPC:
    Type: AWS::EC2::VPC
    Properties:
      EnableDnsSupport: true
      EnableDnsHostnames: true
      CidrBlock: !FindInMap [SubnetConfig, VPC, CIDR]
  PrivateSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select
        - 0
        - Fn::GetAZs: !Ref "AWS::Region"
      VpcId: !Ref "VPC"
      CidrBlock: !FindInMap ["SubnetConfig", "Private", "CIDR"]
  PublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select
        - 0
        - Fn::GetAZs: !Ref "AWS::Region"
      VpcId: !Ref "VPC"
      CidrBlock: !FindInMap ["SubnetConfig", "Public", "CIDR"]
  InternetGateway:
    Type: AWS::EC2::InternetGateway
  GatewayAttachement:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway
  NatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt EIP.AllocationId
      SubnetId: !Ref PublicSubnet
  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref "VPC"
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref "VPC"
  PrivateRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref "PrivateRouteTable"
      DestinationCidrBlock: "0.0.0.0/0"
      NatGatewayId: !Ref NatGateway
  PublicRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref "PublicRouteTable"
      DestinationCidrBlock: "0.0.0.0/0"
      GatewayId: !Ref "InternetGateway"
  PrivateSubnetRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet
      RouteTableId: !Ref PrivateRouteTable
  PublicSubnetRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet
      RouteTableId: !Ref PublicRouteTable

  EC2SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Access to EC2 Instances
      VpcId: !Ref "VPC"

  ImageBuilderInstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Path: "/"
      Roles:
        - !Ref ImageBuilderRole

  BuildLogBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: buildlog
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      LifecycleConfiguration:
        Rules:
          - Status: Enabled
            ExpirationInDays: 30
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 7

Outputs:
  PrivateSubnetId:
    Value: !Ref PrivateSubnet
  EC2SecurityGroupId:
    Value: !Ref EC2SecurityGroup
  BuildLogBucket:
    Value: !Ref BuildLogBucket
  ImageBuilderInstanceProfile:
    Value: !Ref ImageBuilderInstanceProfile

Ruby

CloudFormationのスタックから必要な設定値を取得するようにしました。 ここはコマンド引数などで与えてもよいですし、アプリ等に組み込むなら関数にして引数として与えてもよいでしょう。

require "aws-sdk"
require "securerandom"

semantic_version = Time.now.strftime('0.%Y%m%d.%H%M%S')
imagebuilder = Aws::Imagebuilder::Client.new(region: AWS_REGION)

def get_stack_output(name)
  stack_outputs[name]
end

def stack_outputs
  return @stack_outputs if defined?(@stack_outputs)

  cloud_formation = Aws::CloudFormation::Client.new(region: AWS_REGION)
  stack = cloud_formation.describe_stacks(stack_name: STACK_NAME).stacks[0]
  @stack_outputs = stack.outputs.inject({}) { |memo, output| memo.tap { |memo| memo[output.output_key] = output.output_value } }
end

component_build_version_arn = imagebuilder.create_component(
  name: COMPONENT_NAME, # required
  semantic_version: semantic_version, # required
  platform: "Linux", # required, accepts Windows, Linux
  data: COMPONENT_YAML,
  client_token: SecureRandom.uuid, # required
).component_build_version_arn

image_recipe_arn = imagebuilder.create_image_recipe(
  name: RECIPE_NAME,
  semantic_version: semantic_version, # required
  components: [ # required
    {
      component_arn: 'arn:aws:imagebuilder:ap-northeast-1:aws:component/update-linux/1.0.0',
    },
    {
      component_arn: component_build_version_arn,
    },
    {
      component_arn: 'arn:aws:imagebuilder:ap-northeast-1:aws:component/simple-boot-test-linux/1.0.0',
    },
    {
      component_arn: 'arn:aws:imagebuilder:ap-northeast-1:aws:component/reboot-test-linux/1.0.0',
    }
  ],
  parent_image: "arn:aws:imagebuilder:ap-northeast-1:aws:image/amazon-linux-2-x86/x.x.x", # required
  block_device_mappings: [
    {
      device_name: "/dev/xvda",
      ebs: {
        encrypted: false,
        delete_on_termination: true,
        volume_size: 16,
        volume_type: "gp2", # accepts standard, io1, gp2, sc1, st1
      }
    },
  ],
  client_token: SecureRandom.uuid, # required
).image_recipe_arn

infrastructure_configuration_arn = imagebuilder.create_infrastructure_configuration({
  name: INFRASTRUCTURE_CONFIGURATION_NAME, # required
  instance_types: ["m5a.large"],
  instance_profile_name: get_stack_output('ImageBuilderInstanceProfile'), # required
  security_group_ids: [get_stack_output('EC2SecurityGroupId')],
  subnet_id: get_stack_output('PrivateSubnetId'),
  logging: {
    s3_logs: {
      s3_bucket_name: get_stack_output('BuildLogBucket'),
      s3_key_prefix: "path/to/logs/",
    },
  },
  terminate_instance_on_failure: true,
  client_token: SecureRandom.uuid, # required
}).infrastructure_configuration_arn

distribution_configuration_arn = imagebuilder.create_distribution_configuration({
  name: DISTRIBUTION_CONFIGURATION_NAME, # required
  distributions: [ # required
    {
      region: "ap-northeast-1", # required
      ami_distribution_configuration: {
        name: IMAGE_NAME, # e.g. "my-app {{imagebuilder:buildDate}}"
        ami_tags: {
          "TagName" => "TagValue"
        },
      }
    },
    {
      region: "us-west-2", # required
      ami_distribution_configuration: {
        name: IMAGE_NAME,
        ami_tags: {
          "TagName" => "TagValue"
        },
      }
    },
  ],
  client_token: SecureRandom.uuid, # required
}).distribution_configuration_arn


image_pipeline_arn = imagebuilder.list_image_pipelines(
  filters: [
    {
      name: "name",
      values: [IMAGE_PIPELINE_NAME],
    },
  ],
).image_pipeline_list[0]&.arn
if image_pipeline_arn
  imagebuilder.delete_image_pipeline(image_pipeline_arn: image_pipeline_arn)
end
imagebuilder.create_image_pipeline({
  name: IMAGE_PIPELINE_NAME, # required
  image_recipe_arn: image_recipe_arn, # required
  infrastructure_configuration_arn: infrastructure_configuration_arn, # required
  distribution_configuration_arn: distribution_configuration_arn,
  image_tests_configuration: {
    image_tests_enabled: true,
    timeout_minutes: 60,
  },
  schedule: {
    schedule_expression: "cron(0 0 * * tue)",
    pipeline_execution_start_condition: "EXPRESSION_MATCH_AND_DEPENDENCY_UPDATES_AVAILABLE", # accepts EXPRESSION_MATCH_ONLY, EXPRESSION_MATCH_AND_DEPENDENCY_UPDATES_AVAILABLE
  },
  status: "DISABLED", # accepts DISABLED, ENABLED
  client_token: SecureRandom.uuid, # required
})

ApplicationLoadBalancer で Basic認証

まとめ

  • リスナールールで Authorization ヘッダーをチェックする
  • Lambda で WWW-Authenticate: Basic を返す
  • アプリケーションの実装なしに簡易認証できて便利

事の起こり

  • ALB をフロントに置いたWebサイト
  • 全体に認証をかけたい
  • サーバー側コード等の変更は加えない

というわけで、ALB側の工夫でBasic認証をかけることにしました。

もう少し本格的な認証がほしい場合は Cognito User Pool などと組み合わせたOIDC認証機能が便利です。 今回は利用側のリテラシ的にも要件的にもBasic認証で必要十分だったため、Basic認証を使う方法を検討しました。

ALB設定

ALBはHTTPヘッダーなどをもとに、ルールベースで柔軟な応答を組み立てることができます。

今回は

  • 認証OKかどうか Authorization ヘッダーで確認し、OKなら通す
  • 認証NGの場合は Lambda ファンクションを使い、WWW-Authenticate: Basic を返す

方法にしました。 固定レスポンスで WWW-Authenticate: Basic を返せれば Lambda は不要だったのですが、現状無理なようなので、このようになりました。

実際の画面

まず、実際の画面を見てください。

f:id:uzuki05:20200406232707p:plain
Basic認証を要求

f:id:uzuki05:20200406232736p:plain
Basic認証NG

f:id:uzuki05:20200406232753p:plain
Basic認証OK

認証OKかどうか Authorization ヘッダーで確認し、OKなら通す

CloudFormationでは Field: http-header で HTTPヘッダーのルールを書けます。 次の例では Parameters で与えた Username Password を連結したものをBase64エンコードしたものと、Authorization HTTP ヘッダーの値が一致するとき、固定レスポンスとして成功を返すものです。 実際の利用ではEC2インスタンスやECSなどへ forward することになると思います。

  LoadBalancerListenerAuthorizedRule:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      Actions:
      - Type: fixed-response
        FixedResponseConfig:
          StatusCode: 200
          MessageBody: Authorized
          ContentType: text/plain
      Conditions:
      - Field: http-header
        HttpHeaderConfig:
          HttpHeaderName: Authorization
          Values:
            - Fn::Join:
                - " "
                - - "Basic"
                  - Fn::Base64: !Sub ${Username}:${Password}
      ListenerArn: !Ref LoadBalancerListener
      Priority: 10

認証NGの場合は Lambda ファンクションを使い、WWW-Authenticate: Basic を返す

上記の LoadBalancerListenerAuthorizedRule よりも優先度の低い( Priority の値が大きい)ルールで Lambda ファンクションへ転送します。 今回は簡単のためデフォルトのアクション(いずれのルールにもマッチしなかった場合)を使いました。

この Lambda ファンクションは単に 401WWW-Authenticate: Basic を返すだけです。 ALBから実行できるように、 Principal: elasticloadbalancing.amazonaws.com な権限をつけます。

  LoadBalancerListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - Type: forward
          TargetGroupArn:
            !Ref LoadBalancerTargetGroupUnauthorized
      LoadBalancerArn: !Ref LoadBalancer
      Port: 80
      Protocol: HTTP

# DefaultActions を使わない場合は例えば次のようなルールを使う
#  LoadBalancerListenerUnauthorizedRule:
#    Type: AWS::ElasticLoadBalancingV2::ListenerRule
#    Properties:
#      Actions:
#      - Type: forward
#        TargetGroupArn:
#          !Ref LoadBalancerTargetGroupUnauthorized
#      ListenerArn: !Ref LoadBalancerListener
#      Priority: 20

LoadBalancerTargetGroupUnauthorized:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      TargetType: lambda
      Targets:
        - Id: !GetAtt LambdaSendUnauthrized.Arn
    DependsOn:
      - LambdaPermissionSendUnauthrized
  LambdaSendUnauthrized:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |+
          const debug = (key, object) => { console.log(`DEBUG: ${key}\n`, JSON.stringify(object)); }

          exports.handler = async (event, context) => {
            console.log("INFO: request Recieved.\nEvent:\n", JSON.stringify(event));

            return {
                statusCode: 401,
                statusDescription: '401 Unauthorized',
                body: 'Unauthorized',
                isBase64Encoded: false,
                headers: {
                    'WWW-Authenticate': 'Basic',
                    'Content-Type': 'text/html'
                }
            };
          };
      Handler: index.handler
      Role: !GetAtt LambdaSendUnauthrizedExecutionRole.Arn
      Runtime: "nodejs10.x"
      MemorySize: 128
      Timeout: 15
  LambdaSendUnauthrizedExecutionRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - "sts:AssumeRole"
      Path: /
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
  LambdaPermissionSendUnauthrized:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt LambdaSendUnauthrized.Arn
      Principal: elasticloadbalancing.amazonaws.com

CloudFormation テンプレート

Parameters:
  Username:
    Default: ""
    Type: String
  Password:
    Default: ""
    Type: String

Mappings:
  SubnetConfig:
    VPC:
      CidrBlock: 10.0.0.0/16
    Public1:
      CidrBlock: 10.0.0.0/24
      AvailabilityZone: us-east-1a
    Public2:
      CidrBlock: 10.0.1.0/24
      AvailabilityZone:  us-east-1c

Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      EnableDnsSupport: true
      EnableDnsHostnames: true
      CidrBlock: !FindInMap [SubnetConfig, VPC, CidrBlock]
  PublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref "VPC"
      CidrBlock: !FindInMap ["SubnetConfig", "Public1", "CidrBlock"]
      AvailabilityZone: !FindInMap ["SubnetConfig", "Public1", "AvailabilityZone"]
  PublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref "VPC"
      CidrBlock: !FindInMap ["SubnetConfig", "Public2", "CidrBlock"]
      AvailabilityZone: !FindInMap ["SubnetConfig", "Public2", "AvailabilityZone"]
  InternetGateway:
    Type: AWS::EC2::InternetGateway
  GatewayAttachement:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref "VPC"
  PublicRoute:
    Type: AWS::EC2::Route
    DependsOn: GatewayAttachement
    Properties:
      RouteTableId: !Ref "PublicRouteTable"
      DestinationCidrBlock: "0.0.0.0/0"
      GatewayId: !Ref "InternetGateway"
  PublicSubnetRouteTableAssociation1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet1
      RouteTableId: !Ref PublicRouteTable
  PublicSubnetRouteTableAssociation2:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet2
      RouteTableId: !Ref PublicRouteTable

  LoadBalancerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Access to the LoadBalancer from Internet
      VpcId: !Ref "VPC"
  LoadBalancerSecurityGroupIngressHTTP:
    Type: "AWS::EC2::SecurityGroupIngress"
    Properties:
      Description: HTTP
      GroupId: !Ref LoadBalancerSecurityGroup
      CidrIp: 0.0.0.0/0
      FromPort: 80
      ToPort: 80
      IpProtocol: tcp

  LoadBalancer:
    Type: 'AWS::ElasticLoadBalancingV2::LoadBalancer'
    Properties:
      Subnets:
        - !Ref PublicSubnet1
        - !Ref PublicSubnet2
      SecurityGroups:
        - !Ref LoadBalancerSecurityGroup

  LoadBalancerListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - Type: forward
          TargetGroupArn:
            !Ref LoadBalancerTargetGroupUnauthorized
      LoadBalancerArn: !Ref LoadBalancer
      Port: 80
      Protocol: HTTP
  LoadBalancerListenerAuthorizedRule:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      Actions:
      - Type: fixed-response
        FixedResponseConfig:
          StatusCode: 200
          MessageBody: Authorized
          ContentType: text/plain
      Conditions:
      - Field: http-header
        HttpHeaderConfig:
          HttpHeaderName: Authorization
          Values:
            - Fn::Join:
                - " "
                - - "Basic"
                  - Fn::Base64: !Sub ${Username}:${Password}
      ListenerArn: !Ref LoadBalancerListener
      Priority: 10

  LoadBalancerTargetGroupUnauthorized:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      TargetType: lambda
      Targets:
        - Id: !GetAtt LambdaSendUnauthrized.Arn
    DependsOn:
      - LambdaPermissionSendUnauthrized
  LambdaSendUnauthrized:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |+
          const debug = (key, object) => { console.log(`DEBUG: ${key}\n`, JSON.stringify(object)); }

          exports.handler = async (event, context) => {
            console.log("INFO: request Recieved.\nEvent:\n", JSON.stringify(event));

            return {
                statusCode: 401,
                statusDescription: '401 Unauthorized',
                body: 'Unauthorized',
                isBase64Encoded: false,
                headers: {
                    'WWW-Authenticate': 'Basic',
                    'Content-Type': 'text/html'
                }
            };
          };
      Handler: index.handler
      Role: !GetAtt LambdaSendUnauthrizedExecutionRole.Arn
      Runtime: "nodejs10.x"
      MemorySize: 128
      Timeout: 15
  LambdaSendUnauthrizedExecutionRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - "sts:AssumeRole"
      Path: /
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
  LambdaPermissionSendUnauthrized:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt LambdaSendUnauthrized.Arn
      Principal: elasticloadbalancing.amazonaws.com

Outputs:
  LoadBalancerDNSName:
    Value: !GetAtt LoadBalancer.DNSName