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 は不要だったのですが、現状無理なようなので、このようになりました。
- アプリケーションロードバランサー(ALB)のターゲットにAWS Lambdaが選択可能になりました
- 新機能 – AWS Application Load Balancer 向けの高度なリクエストルーティング
実際の画面
まず、実際の画面を見てください。
認証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 ファンクションは単に 401
と WWW-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