AWSマネジメントコンソールログイン通知をメールで受け取る

## 概要
AWSマネジメントコンソールにログインしたイベントを、メールで通知させる仕組みを構築します。知らないところで誰かに勝手にログインされても気がつけるようになります。CloudFormationテンプレートで、簡単に導入できるようにしました。

## 構成
今回構築するシステム構成です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/fa201630-ed52-46a7-2f78-ad1b2f6a2529.png)

## 通知サンプル
以下のようなメールを受け取ることができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/607b8c69-a3dd-4dbe-8253-431f347b45d9.png)

※ログ情報の一部をそのまま連携しただけなので、見た目が悪いです。本文の修正は、別の記事で紹介する予定です。

## 各コンポーネントの設定

それでは、各コンポーネントを説明してきます。

### マネジメントコンソール
AWSをブラウザ経由で使用する時のログイン先です。時々、ログイン先のリージョンが変わります。そのためのすべてのリージョンのイベントを監視する必要があります。

### CloudTrail
ログを記録するためのセキュリティ関連サービスです。ログ保存先としてS3バケットを指定します。また、すべてのリージョンのログを記録するように設定します。

マネジメントコンソールでログインした場合、イベント「ConsoleLogin」が記録されます。私の場合シドニー（ap-southeast-2）に記録されていました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/bb9b55fe-fd21-a6f1-408c-5c88e3114a73.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/ce1b2c55-8351-085e-e90d-b49ac5dfb9ba.png)

CloudTrailのログはS3バケット以外に、CloudWatch Logsの指定したロググループへ連携すること（オプション）ができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/c2f74b1d-03b8-c2b5-ad84-cd998ce5f1b4.png)

### CloudWatch Logs
ロググループにサブスクリプションを設定し、イベント「ConsoleLogin」を受信した場合、通知と記録のためのLambda関数を起動します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/aba0ce75-5cb4-018d-58d9-b941ae7c2c28.png)

サブスリプションフィルターのパターンには、{ $.eventName = "ConsoleLogin" }と設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/21ec9dcb-5533-769a-3dcb-6c65d0e7165a.png)

### Lambda関数
受け取った「ConsoleLogin」イベント情報をデータテーブルへ出力します。また、メール通知するための件名と本文を作成します。

他のリソースへのアクセスが必要ですので、Lambda実行ロールに権限を付与します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/40c4e613-6cb4-82ac-aedc-64e02c6b3943.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/903fbb94-2b5c-8398-f1cf-16055ea3cf98.png)

### ログインイベントデータテーブル
ログインイベントデータを整形し、DynamoDBテーブルに格納します。UserNameにはコンソールにログインしたIAMUser名（Rootユーザでログインした場合はRootと表示）やログイン日時、アクセス元のIPアドレス情報ｎが記録されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/3713cb70-1ec9-21de-746a-b5149d3ffb33.png)
DynamoDBテーブルでは、データレコードに有効期限を設定できます。unixtime(TTL)が期限を示しており、eventTimeの90日後に設定しています。有効期限を超えた古いレコードはAWSが自動的に削除を行います。

### SendEmailTopic
通知先のメールアドレスを登録します。

以下のようにメールアドレスを登録してください。
マネジメントコンソールより Amazon SNS > トピック > SendEmailTopic-xxxxxxxx へ移動し、「サブスクリプションの作成」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/b22eef89-5675-4930-4fd0-c3f019aaa90c.png)

サブスクリプションの作成画面で、プロトコルに「Eメール」、エンドポイントに送信先のメールアドレスを入力します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/2dab4674-381d-f8fb-8318-1c74c626d534.png)

ステータスが 保留中の確認 となります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/d7500bf1-b279-ac16-0d28-ace51cf34d03.png)

以下のようなメールが送信されてくるので、「Confirm subscription」を右クリックして、リンクのURLをコピー※
※そのままクリックすると、SNS通知メールに含まれるサブスクリプション解除リンクが有効となってしまうため、せっかく通知を受け取れる状態になっても誤ってメール配信を解除してしまう可能性があります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/57c4e199-70c7-408b-1d74-b07767c6cec4.png)

SNSの画面に戻り、保留中の確認ステータスとなっているサブスクリプションを選択し、「サブスクリプションの確認」をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/a24dc19c-20e5-a4a8-5541-22f4f31351aa.png)

サブスクリプションの確認に、先程コピーしたURLのリンクを貼り付けて、「サブスクリプションの確認」をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/68ad4f49-095d-ac91-f72d-411a88eb297a.png)

ステータスが「確認済み」に変わります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/beafeb1e-4c7f-19e1-27b6-759d86b68284.png)

以上で完成です。

## CFnテンプレート
今回2つのテンプレートを作成しました。

### CloudTrail設定テンプレート
本テンプレートでは、CloudTrailとログ保存先のS3バケット、ロググループ、それらに付随する関連リソースを作成します。CloudTrailは設定済みであり、CloudWatch Logsに連携するように設定しているのであれば、本テンプレートの展開は必要ありません。

``` yaml:CloudTrail-template.yaml
AWSTemplateFormatVersion: 2010-09-09
Description: Create CloudTrail trail template

Parameters:
  ResourceSuffixName:
    Type: "String"
    Default: "ab0925cd"
    Description: "This suffix name will be added to newly created resource's name."
  
Resources:
  CloudTrailLogGroup:
    UpdateReplacePolicy: "Retain"
    Type: "AWS::Logs::LogGroup"
    DeletionPolicy: "Delete"
    Properties:
      LogGroupClass: "STANDARD"
      RetentionInDays: 30
      LogGroupName: !Sub "aws-cloudtrail-loggroup-${AWS::AccountId}-${ResourceSuffixName}"
      DataProtectionPolicy: {}
  CloudTrailRoleForCloudWatchLogs:
    UpdateReplacePolicy: "Retain"
    Type: "AWS::IAM::Role"
    DeletionPolicy: "Delete"
    Properties:
      Path: "/service-role/"
      ManagedPolicyArns:
      - !Ref CloudTrailRolePolicyForCloudWatchLogs
      MaxSessionDuration: 3600
      RoleName: "CloudTrailRoleForCloudWatchLogs"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
        - Action: "sts:AssumeRole"
          Effect: "Allow"
          Principal:
            Service: "cloudtrail.amazonaws.com"
  CloudTrailTrail:
    UpdateReplacePolicy: "Retain"
    Type: "AWS::CloudTrail::Trail"
    DeletionPolicy: "Delete"
    DependsOn: S3BucketPolicy
    Properties:
      IncludeGlobalServiceEvents: true
      EventSelectors: []
      CloudWatchLogsRoleArn:
        Fn::GetAtt:
        - "CloudTrailRoleForCloudWatchLogs"
        - "Arn"
      AdvancedEventSelectors:
      - FieldSelectors:
        - Field: "readOnly"
          Equals:
          - "false"
        - Field: "eventCategory"
          Equals:
          - "Management"
        Name: "Management events selector"
      TrailName: !Sub "Management-events-${ResourceSuffixName}"
      InsightSelectors: []
      CloudWatchLogsLogGroupArn:
        Fn::GetAtt:
        - "CloudTrailLogGroup"
        - "Arn"
      IsMultiRegionTrail: true
      S3BucketName:
        Ref: "S3BucketForCloudtraillogs"
      EnableLogFileValidation: true
      Tags: []
      IsLogging: true
  CloudTrailRolePolicyForCloudWatchLogs:
    UpdateReplacePolicy: "Retain"
    Type: "AWS::IAM::ManagedPolicy"
    DeletionPolicy: "Delete"
    Properties:
      ManagedPolicyName: "CloudTrailRolePolicyForCloudWatchLogs"
      Path: "/service-role/"
      Description: "CloudTrail role to send logs to CloudWatch Logs"
      Groups: []
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
        - Resource:
          - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:aws-cloudtrail-loggroup-${AWS::AccountId}-${ResourceSuffixName}:*"
          Action:
          - "logs:CreateLogStream"
          Effect: "Allow"
          Sid: "AWSCloudTrailCreateLogStream2014110"
        - Resource:
          - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:aws-cloudtrail-loggroup-${AWS::AccountId}-${ResourceSuffixName}:*"
          Action:
          - "logs:PutLogEvents"
          Effect: "Allow"
          Sid: "AWSCloudTrailPutLogEvents20141101"
  S3BucketPolicy:
    UpdateReplacePolicy: "Retain"
    Type: "AWS::S3::BucketPolicy"
    DeletionPolicy: "Delete"
    Properties:
      Bucket:
        Ref: "S3BucketForCloudtraillogs"
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
        - Condition:
            StringEquals:
              AWS:SourceArn: !Sub "arn:aws:cloudtrail:${AWS::Region}:${AWS::AccountId}:trail/Management-events-${ResourceSuffixName}"
          Resource: !Sub "arn:aws:s3:::aws-cloudtrail-logs-${AWS::AccountId}-${ResourceSuffixName}"
          Action: "s3:GetBucketAcl"
          Effect: "Allow"
          Principal:
            Service: "cloudtrail.amazonaws.com"
          Sid: "AWSCloudTrailAclCheck20150319"
        - Condition:
            StringEquals:
              AWS:SourceArn: !Sub "arn:aws:cloudtrail:${AWS::Region}:${AWS::AccountId}:trail/Management-events-${ResourceSuffixName}"
              s3:x-amz-acl: "bucket-owner-full-control"
          Resource: !Sub "arn:aws:s3:::aws-cloudtrail-logs-${AWS::AccountId}-${ResourceSuffixName}/AWSLogs/${AWS::AccountId}/*"
          Action: "s3:PutObject"
          Effect: "Allow"
          Principal:
            Service: "cloudtrail.amazonaws.com"
          Sid: "AWSCloudTrailWrite20150319"
  S3BucketForCloudtraillogs:
    UpdateReplacePolicy: "Retain"
    Type: "AWS::S3::Bucket"
    DeletionPolicy: "Delete"
    Properties:
      PublicAccessBlockConfiguration:
        RestrictPublicBuckets: true
        IgnorePublicAcls: true
        BlockPublicPolicy: true
        BlockPublicAcls: true
      LifecycleConfiguration:
        Rules:
        - Status: "Enabled"
          ExpirationInDays: 60
          Id: "60 days retention"
      BucketName: !Sub "aws-cloudtrail-logs-${AWS::AccountId}-${ResourceSuffixName}"
      OwnershipControls:
        Rules:
        - ObjectOwnership: "BucketOwnerEnforced"
      BucketEncryption:
        ServerSideEncryptionConfiguration:
        - BucketKeyEnabled: false
          ServerSideEncryptionByDefault:
            SSEAlgorithm: "AES256"

```
### ログイン通知処理テンプレート
CloudTrailのログ情報が連携されているロググループを指定して、ログループのLambdaサブスクリプションやDynamoDBテーブル、SNSトピック、そららに付随する関連リソースを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3636209/87c05854-3b4e-e621-7038-3a2e0ef3db52.png)
テンプレート展開後は、SNSトピックへのメールアドレス登録は手動で行ってください。

```yaml:ConsoleLoginNotification-template.yaml
AWSTemplateFormatVersion: 2010-09-09
Description: Console Login event notification template

Parameters:
  ResourceSuffixName:
    Type: "String"
    Default: "ab0925cd"
    Description: "This suffix name will be added to newly created resource's name."
  
  ExistingCloudTrailLogGroupName:
    Type: "String"
    Description: "The name of an existing CloudTrail log group."
  
Resources:
  LogGroupSubscriptionFilter:
    UpdateReplacePolicy: "Retain"
    Type: "AWS::Logs::SubscriptionFilter"
    DeletionPolicy: "Delete"
    DependsOn: ConsoleLoginNotificationLambdaPermission
    Properties:
      FilterPattern: "{ $.eventName = \"ConsoleLogin\" }"
      LogGroupName: !Sub ${ExistingCloudTrailLogGroupName}
      FilterName: "ConsoleLogin"
      DestinationArn:
        Fn::GetAtt:
        - "ConsoleLoginNotificationLambda"
        - "Arn"
      Distribution: "ByLogStream"
  ConsoleLoginEventDataTable:
    UpdateReplacePolicy: "Retain"
    Type: "AWS::DynamoDB::Table"
    DeletionPolicy: "Delete"
    Properties:
      OnDemandThroughput:
        MaxReadRequestUnits: 1
        MaxWriteRequestUnits: 1
      SSESpecification:
        SSEEnabled: false
      TableName: "ConsoleLoginEventDataTable"
      AttributeDefinitions:
      - AttributeType: "S"
        AttributeName: "eventID"
      ContributorInsightsSpecification:
        Enabled: false
      BillingMode: "PAY_PER_REQUEST"
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: false
      KeySchema:
      - KeyType: "HASH"
        AttributeName: "eventID"
      DeletionProtectionEnabled: false
      TableClass: "STANDARD"
      Tags: []
      TimeToLiveSpecification:
        Enabled: true
        AttributeName: "unixtime"
  ConsoleLoginNotificationLambdaRolePolicy:
    UpdateReplacePolicy: "Retain"
    Type: "AWS::IAM::ManagedPolicy"
    DeletionPolicy: "Delete"
    Properties:
      ManagedPolicyName: "ConsoleLoginNotificationLambdaRolePolicy"
      Path: "/service-role/"
      Description: ""
      Groups: []
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
        - Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"
          Action: "logs:CreateLogGroup"
          Effect: "Allow"
        - Resource:
          - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/ConsoleLoginNotificationLambda:*"
          Action:
          - "logs:CreateLogStream"
          - "logs:PutLogEvents"
          Effect: "Allow"
        - Resource:
          - !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/ConsoleLoginEventDataTable"
          Action:
          - "dynamodb:PutItem"
          - "dynamodb:Scan"
          - "dynamodb:GetItem"
          Effect: "Allow"
          Sid: "AllowDynamoDB"
        - Resource:
          - !Sub "arn:aws:sns:${AWS::Region}:${AWS::AccountId}:SendEmailTopic-${ResourceSuffixName}"
          Action:
          - "sns:Publish"
          Effect: "Allow"
          Sid: "AllowSNS"
  SendEmailTopic:
    UpdateReplacePolicy: "Retain"
    Type: "AWS::SNS::Topic"
    DeletionPolicy: "Delete"
    Properties:
      FifoTopic: false
      TracingConfig: "PassThrough"
      TopicName: !Sub "SendEmailTopic-${ResourceSuffixName}"
  ConsoleLoginNotificationLambdaLogGroup:
    UpdateReplacePolicy: "Retain"
    Type: "AWS::Logs::LogGroup"
    DeletionPolicy: "Delete"
    Properties:
      LogGroupClass: "STANDARD"
      RetentionInDays: 30
      LogGroupName: "/aws/lambda/ConsoleLoginNotificationLambda"
      DataProtectionPolicy: {}
  ConsoleLoginNotificationLambdaPermission:
    UpdateReplacePolicy: "Retain"
    Type: "AWS::Lambda::Permission"
    DeletionPolicy: "Delete"
    Properties:
      FunctionName:
        Fn::GetAtt:
        - "ConsoleLoginNotificationLambda"
        - "Arn"
      Action: "lambda:InvokeFunction"
      SourceArn: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${ExistingCloudTrailLogGroupName}:*"
      Principal: "logs.amazonaws.com"
      SourceAccount: !Sub ${AWS::AccountId}
  ConsoleLoginNotificationLambdaRole:
    UpdateReplacePolicy: "Retain"
    Type: "AWS::IAM::Role"
    DeletionPolicy: "Delete"
    Properties:
      Path: "/service-role/"
      ManagedPolicyArns:
      - !Ref ConsoleLoginNotificationLambdaRolePolicy
      MaxSessionDuration: 3600
      RoleName: "ConsoleLoginNotificationLambdaRole"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
        - Action: "sts:AssumeRole"
          Effect: "Allow"
          Principal:
            Service: "lambda.amazonaws.com"
  ConsoleLoginNotificationLambda:
    UpdateReplacePolicy: "Retain"
    Type: "AWS::Lambda::Function"
    DeletionPolicy: "Delete"
    Properties:
      MemorySize: 128
      Description: ""
      TracingConfig:
        Mode: "PassThrough"
      Timeout: 10
      RuntimeManagementConfig:
        UpdateRuntimeOn: "Auto"
      Handler: "index.lambda_handler"
      Role:
        Fn::GetAtt:
        - "ConsoleLoginNotificationLambdaRole"
        - "Arn"
      FileSystemConfigs: []
      FunctionName: "ConsoleLoginNotificationLambda"
      LoggingConfig:
        LogGroup: !Ref ConsoleLoginNotificationLambdaLogGroup
      Runtime: "python3.11"
      PackageType: "Zip"
      Environment:
        Variables:
          TOPIC_ARN: !Sub "arn:aws:sns:${AWS::Region}:${AWS::AccountId}:SendEmailTopic-${ResourceSuffixName}"
      EphemeralStorage:
        Size: 512
      Architectures:
        - "x86_64"
      Code:
          ZipFile: |
            # coding: utf-8
            import json
            import base64
            import gzip
            import os
            import boto3
            import datetime
            from dateutil import tz

            def lambda_handler(event, context):
                
                print(event)
                
                decoded_data = Decode_event(event)
                logEvents = decoded_data['logEvents']
                for logEvent in logEvents:
                    
                    inputData = create_inputData(logEvent['message'])
                    
                    response = Store_Data(inputData)
                    
                    # proccess email
                    if response['statusCode'] == 200:
                        subject = 'AWSコンソールログイン'
                        message = json.dumps(inputData)
                        
                        try:
                            response = Publish_sns(subject, message)
                        except Exception as e:
                            print(e)
                            print(response)
                            return{
                                'statusCode': 500,
                                'body': 'Error Publishing event!'
                                
                            }

                return {
                        'statusCode': 200,
                        'body': 'Console Login Event Published successfully.'
                }
                

            def create_inputData(strevent):
                
                print(strevent)
                event = json.loads(strevent)
                
                utcTime = datetime.datetime.strptime(event['eventTime'], '%Y-%m-%dT%H:%M:%SZ')
                ttlTime = utcTime + datetime.timedelta(days=180)
                japanTime = utcTime.astimezone(tz.gettz('Asia/Tokyo'))
                
                inputData = {}
                inputData['eventID'] = event['eventID']
                inputData['accountId'] = event['userIdentity']['accountId']
                match event['userIdentity']['type']:
                    case 'IAMUser':
                        inputData['userName'] = event['userIdentity']['userName']
                    case 'Root':
                        inputData['userName'] = 'Root'
                inputData['eventTime'] = japanTime.strftime('%Y/%m/%d %H:%M:%S')
                inputData['eventSource'] = event['eventSource']
                inputData['eventName'] = event['eventName']
                inputData['awsRegion'] = event['awsRegion']
                inputData['sourceIPAddress'] = event['sourceIPAddress']
                inputData['userAgent'] = event['userAgent']
                inputData['unixtime'] = int(datetime.datetime.timestamp(ttlTime))
                
                return inputData
                
                
            def Store_Data(inputData):
                
                dynamodb = boto3.resource('dynamodb')
                ConsoleLoginEventDataTable = dynamodb.Table('ConsoleLoginEventDataTable')

                try:
                    response = ConsoleLoginEventDataTable.put_item(Item=inputData)
                    #
                    # response = ConsoleLoginEventDataTable.put_item(
                    #     Item=inputData,
                    #     conditionExpression=Attr('eventID').not_exists()
                    # )
                    return {
                        'statusCode': 200,
                        'body': 'Console Login Event stored successfully.'
                    }
                except Exception as e:
                    print(e)
                    print(inputData)
                    return{
                        'statusCode': 500,
                        'body': 'Error storing event!'
                        
                    }


            def Publish_sns(subject, message):
                
                sns = boto3.client('sns')
                topicArn = os.environ['TOPIC_ARN']
                
                response = sns.publish(
                    TopicArn=topicArn,
                    Subject=subject,
                    Message=message
                )
                
                return response
                

            def Decode_event(event):
                
                decode_data = base64.b64decode(event['awslogs']['data'])
                
                json_data = json.loads(gzip.decompress(decode_data))
                
                return json_data
```

## まとめ
今回、CloudTrailのログイベントとLambda関数を使用して、AWSマネジメントコンソールへのログイン情報をメールで通知する仕組みを作成しました。CloudTrailはAWSの操作を記録する基本的なサービスですので、セキュリティのために設定することをおすすめします。