タケユー・ウェブ日報

Webシステム受託会社の業務の中での気づきや調べごとのメモ。

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