Cognitoの多要素認証(MFA)をTerraformでまとめて設定してみた。

Cognitoの多要素認証(MFA)をTerraformでまとめて設定してみた。

Clock Icon2025.07.17

はじめに

皆様こんにちは、あかいけです。
突然ですが AWS Cognito では以下の種類の多要素認証 (MFA) を設定できます。

  • TOTP ソフトウェアトークン MFA
  • SMS メッセージ MFA
  • E メールメッセージ MFA

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-settings-mfa.html

いずれの方法も自前で実装するよりかなり簡単に実装できますが、
それぞれ設定方法が異なっており、一通り試してみると意外と面倒でした。

というわけで、
今回は Terraform で多要素認証 (MFA) をまとめて設定してみました。
なお構成自体は以下の記事と同じで、これに多要素認証の設定を追加しています。

https://dev.classmethod.jp/articles/alb-cognito-terraform/

構成図

今回は ALB + Cognito の構成です。
またパブリック証明書の発行やドメインの登録も Terraform 内に含めています。

shapes-1749456061088

事前準備

事前に準備が必要となるのは、ドメインだけです。
そのため、 Route53 でドメインの取得 + デフォルトで作成されるホストゾーン があれば OK です。

TOTP ソフトウェアトークン MFA

まずは TOTP ソフトウェアトークン MFAでの MFA 設定です。
この認証方式では Cognito 側で設定するほか、クライアント側のデバイスに TOTP アプリのインストールが必要となります。

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-settings-mfa-totp.html

コード全体

コード全体
main.tf
provider "aws" {
  region = "ap-northeast-1"
  default_tags {
    tags = {
      app     = local.app_name
      project = "terraform"
    }
  }
}

locals {
  app_name = "cognito-alb-app"
}

############
## Domain
############

variable "domain" {
  type    = string
  default = "example.com"
}

data "aws_route53_zone" "main" {
  name         = var.domain
  private_zone = false
}

locals {
  cognito_domain = "${local.app_name}.${var.domain}"
}

resource "aws_acm_certificate" "main" {
  domain_name = local.cognito_domain

  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "validation" {
  for_each = {
    for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  zone_id = data.aws_route53_zone.main.id
  name    = each.value.name
  type    = each.value.type
  records = [each.value.record]
  ttl     = 60
}

resource "aws_acm_certificate_validation" "main" {
  certificate_arn         = aws_acm_certificate.main.arn
  validation_record_fqdns = [for record in aws_route53_record.validation : record.fqdn]
}

resource "aws_route53_record" "main" {
  type = "A"

  name    = local.cognito_domain
  zone_id = data.aws_route53_zone.main.id

  alias {
    name                   = aws_lb.alb.dns_name
    zone_id                = aws_lb.alb.zone_id
    evaluate_target_health = true
  }
}

output "site_domain" {
  value = "https://${aws_route53_record.main.fqdn}"
}

############
## Cognito 
############

locals {
  users = {
    test_user = {
      name     = "TestUser1"
      email    = "example1@example.com"
      password = "Temp123!"
    },
    test_user2 = {
      name     = "TestUser2"
      email    = "example2@example.com"
      password = "Temp123!"
    },
    test_user3 = {
      name     = "TestUser3"
      email    = "example3@example.com"
      password = "Temp123!"
    }
  }
}

resource "aws_cognito_user_pool" "main" {
  name = "${local.app_name}-user-pool"

  admin_create_user_config {
    allow_admin_create_user_only = true
  }

  password_policy {
    minimum_length    = 8
    require_lowercase = true
    require_numbers   = true
    require_symbols   = true
    require_uppercase = true
  }

  auto_verified_attributes = ["email"]

  mfa_configuration = "ON"
  software_token_mfa_configuration {
    enabled = true
  }
}

resource "aws_cognito_user" "users" {
  for_each = local.users

  user_pool_id = aws_cognito_user_pool.main.id
  username     = each.value.name

  attributes = {
    email          = each.value.email
    email_verified = true
    name           = each.value.name
  }

  temporary_password = each.value.password
}

resource "aws_cognito_user_pool_client" "main" {
  name                                 = "${local.app_name}-user-pool-client"
  user_pool_id                         = aws_cognito_user_pool.main.id
  generate_secret                      = true
  allowed_oauth_flows                  = ["code"]
  allowed_oauth_scopes                 = ["email", "openid", "profile"]
  allowed_oauth_flows_user_pool_client = true
  callback_urls                        = ["https://${local.cognito_domain}/oauth2/idpresponse"]
  supported_identity_providers         = ["COGNITO"]
}

resource "random_id" "suffix" {
  byte_length = 24
}

resource "aws_cognito_user_pool_domain" "main" {
  domain                = "auth-${random_id.suffix.hex}"
  user_pool_id          = aws_cognito_user_pool.main.id
  managed_login_version = 2
}

############
## Network
############

locals {
  vpc_cidr = "10.0.0.0/16"
  subnets = {
    public1 = {
      cidr_block        = "10.0.1.0/24"
      availability_zone = "ap-northeast-1a"
    }
    public2 = {
      cidr_block        = "10.0.2.0/24"
      availability_zone = "ap-northeast-1c"
    }
  }
}

resource "aws_vpc" "main" {
  cidr_block = local.vpc_cidr
  tags = {
    Name = "${local.app_name}-vpc"
  }
}

resource "aws_subnet" "public" {
  for_each                = local.subnets
  vpc_id                  = aws_vpc.main.id
  cidr_block              = each.value.cidr_block
  map_public_ip_on_launch = true
  availability_zone       = each.value.availability_zone
  tags = {
    Name = "${local.app_name}-${each.key}"
  }
}

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.main.id
  tags = {
    Name = "${local.app_name}-igw"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }
  tags = {
    Name = "${local.app_name}-public-rtb"
  }
}

resource "aws_route_table_association" "public" {
  for_each       = local.subnets
  subnet_id      = aws_subnet.public[each.key].id
  route_table_id = aws_route_table.public.id
}

############
## ALB
############

resource "aws_security_group" "alb_sg" {
  name        = "alb-sg"
  description = "Allow HTTPS"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${local.app_name}-alb-sg"
  }
}

resource "aws_lb_target_group" "tg" {
  name     = "${local.app_name}-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.main.id

  health_check {
    enabled             = true
    interval            = 30
    path                = "/"
    port                = "traffic-port"
    healthy_threshold   = 3
    unhealthy_threshold = 3
    timeout             = 5
    protocol            = "HTTP"
    matcher             = "200"
  }

  tags = {
    Name = "${local.app_name}-target-group"
  }
}

resource "aws_lb" "alb" {
  name               = "${local.app_name}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb_sg.id]
  subnets            = [for s in aws_subnet.public : s.id]
  tags = {
    Name = "${local.app_name}-alb"
  }
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.alb.arn
  port              = 443
  protocol          = "HTTPS"
  certificate_arn   = aws_acm_certificate_validation.main.certificate_arn

  default_action {
    type = "authenticate-cognito"
    authenticate_cognito {
      user_pool_arn              = aws_cognito_user_pool.main.arn
      user_pool_client_id        = aws_cognito_user_pool_client.main.id
      user_pool_domain           = aws_cognito_user_pool_domain.main.domain
      on_unauthenticated_request = "authenticate"
      scope                      = "openid profile email"
      session_cookie_name        = "AWSELBAuthSessionCookie"
      authentication_request_extra_params = {
        lang = "ja"
      }

    }
    order = 1
  }

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.tg.arn
    order            = 2
  }

  tags = {
    Name = "${local.app_name}-listener"
  }

  depends_on = [aws_acm_certificate_validation.main]
}

resource "aws_lb_target_group_attachment" "https" {
  target_group_arn = aws_lb_target_group.tg.arn
  target_id        = aws_instance.web.id
  port             = 80
}

############
## EC2
############

data "aws_ami" "amazonlinux_2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023*-kernel-6.1-x86_64"]
  }
}

resource "aws_security_group" "ec2_sg" {
  name        = "${local.app_name}-ec2-sg"
  description = "Allow HTTP from ALB"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb_sg.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  tags = {
    Name = "${local.app_name}-ec2-sg"
  }
}

resource "aws_instance" "web" {
  ami                    = data.aws_ami.amazonlinux_2023.id
  instance_type          = "t3.micro"
  subnet_id              = aws_subnet.public["public1"].id
  vpc_security_group_ids = [aws_security_group.ec2_sg.id]
  iam_instance_profile   = aws_iam_instance_profile.main.name
  user_data              = <<EOF
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "Hello World" >> /var/www/html/index.html
EOF

  tags = {
    Name = "${local.app_name}-ec2"
  }
}

resource "aws_iam_role" "main" {
  name = "${local.app_name}-ssm-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      }
    ]
  })

  tags = {
    Name = "${local.app_name}-ssm-cloudwatch-role"
  }
}

resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.main.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "main" {
  name = "SSMAndCloudWatchInstanceProfile"
  role = aws_iam_role.main.name
}

output "ssm_start_session" {
  value = "aws ssm start-session --target ${aws_instance.web.id}"
}

MFA 設定

重要な設定は aws_cognito_user_pool 内の以下の設定項目です。
こちらの設定を有効化するだけなので、非常に簡単に設定できますね。

  • mfa_configuration
  • software_token_mfa_configuration
main.tf
resource "aws_cognito_user_pool" "main" {
  name = "${local.app_name}-user-pool"

  admin_create_user_config {
    allow_admin_create_user_only = true
  }

  password_policy {
    minimum_length    = 8
    require_lowercase = true
    require_numbers   = true
    require_symbols   = true
    require_uppercase = true
  }

  auto_verified_attributes = ["email"]

  mfa_configuration = "ON"
  software_token_mfa_configuration {
    enabled = true
  }
}

SMS メッセージ

次は SMS メッセージでの MFA 設定です。
この認証方式では Cognito 側で設定するほか、SMS メッセージを送信するために SNS を設定する必要があります。

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-settings-mfa-sms-email-message.html
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-sms-settings.html

また、デフォルトで SNS での SMS メッセージの送信はサンドボックスとなっており、
事前に検証済みの電話番号にのみ SMS メッセージが送信可能です。
今回は検証なのでサンドボックスの方法で進めますが、本番環境で利用する場合はサンドボックスの解除が前提となります。

事前準備

前述の通り、サンドボックス状態だと事前に電話番号を検証する必要があります。

まず以下コマンドでサンドボックスの送信先電話番号を追加して、

aws sns create-sms-sandbox-phone-number \
  --phone-number "+81XXXXXXXXXXX" \
  --region ap-northeast-1

指定した電話番号に SMS メッセージが送信されるので、
以下のコマンドに送信されたパスワードを指定して実行します。

aws sns verify-sms-sandbox-phone-number \
  --phone-number "+81XXXXXXXXXXX" \
  --one-time-password "XXXXXX" \
  --region ap-northeast-1

その後、以下コマンドで確認して、Status が Verified になっていれば検証完了です。

aws sns list-sms-sandbox-phone-numbers --region ap-northeast-1
{
    "PhoneNumbers": [
        {
            "PhoneNumber": "+81XXXXXXXXXXX",
            "Status": "Verified"
        }
    ]
}

コード全体

コード全体
main.tf
provider "aws" {
  region = "ap-northeast-1"
  default_tags {
    tags = {
      app     = local.app_name
      project = "terraform"
    }
  }
}

locals {
  app_name = "cognito-alb-app"
}

############
## Domain
############

variable "domain" {
  type    = string
  default = "example.com"
}

data "aws_route53_zone" "main" {
  name         = var.domain
  private_zone = false
}

locals {
  cognito_domain = "${local.app_name}.${var.domain}"
}

resource "aws_acm_certificate" "main" {
  domain_name = local.cognito_domain

  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "validation" {
  for_each = {
    for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  zone_id = data.aws_route53_zone.main.id
  name    = each.value.name
  type    = each.value.type
  records = [each.value.record]
  ttl     = 60
}

resource "aws_acm_certificate_validation" "main" {
  certificate_arn         = aws_acm_certificate.main.arn
  validation_record_fqdns = [for record in aws_route53_record.validation : record.fqdn]
}

resource "aws_route53_record" "main" {
  type = "A"

  name    = local.cognito_domain
  zone_id = data.aws_route53_zone.main.id

  alias {
    name                   = aws_lb.alb.dns_name
    zone_id                = aws_lb.alb.zone_id
    evaluate_target_health = true
  }
}

output "site_domain" {
  value = "https://${aws_route53_record.main.fqdn}"
}

############
## Cognito 
############

locals {
  users = {
    test_user = {
      name     = "TestUser1"
      email    = "example1@example.com"
      password = "Temp123!"
    },
    test_user2 = {
      name     = "TestUser2"
      email    = "example2@example.com"
      password = "Temp123!"
    },
    test_user3 = {
      name     = "TestUser3"
      email    = "example3@example.com"
      password = "Temp123!"
    }
  }
}

data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

resource "random_id" "external_id" {
  byte_length = 16
}

resource "aws_cognito_user_pool" "main" {
  name = "${local.app_name}-user-pool"

  admin_create_user_config {
    allow_admin_create_user_only = true
  }

  password_policy {
    minimum_length    = 8
    require_lowercase = true
    require_numbers   = true
    require_symbols   = true
    require_uppercase = true
  }

  auto_verified_attributes = ["email", "phone_number"]

  # SMS MFAの設定
  mfa_configuration = "ON"

  # SMS設定
  sms_configuration {
    external_id    = random_id.external_id.hex
    sns_caller_arn = aws_iam_role.cognito_sns.arn
    sns_region     = data.aws_region.current.name
  }

  # SMS認証に必要な電話番号属性
  schema {
    attribute_data_type = "String"
    name                = "phone_number"
    required            = true
    mutable             = true
  }
}

# SMS MFA用のIAMロール
resource "aws_iam_role" "cognito_sns" {
  name = "${local.app_name}-cognito-sns-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Service = "cognito-idp.amazonaws.com"
        }
        Action = "sts:AssumeRole"
        Condition = {
          StringEquals = {
            "sts:ExternalId"    = random_id.external_id.hex
            "aws:SourceAccount" = data.aws_caller_identity.current.account_id
          }
          ArnLike = {
            "aws:SourceArn" = "arn:aws:cognito-idp:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:userpool/*"
          }
        }
      }
    ]
  })
}

resource "aws_iam_role_policy" "cognito_sns" {
  name = "${local.app_name}-cognito-sns-policy"
  role = aws_iam_role.cognito_sns.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "sns:Publish"
        ]
        Resource = "*"
      }
    ]
  })
}

resource "aws_cognito_user" "users" {
  for_each = local.users

  user_pool_id = aws_cognito_user_pool.main.id
  username     = each.value.name

  attributes = {
    email          = each.value.email
    email_verified = true
    name           = each.value.name
  }

  temporary_password = each.value.password
}

resource "aws_cognito_user_pool_client" "main" {
  name                                 = "${local.app_name}-user-pool-client"
  user_pool_id                         = aws_cognito_user_pool.main.id
  generate_secret                      = true
  allowed_oauth_flows                  = ["code"]
  allowed_oauth_scopes                 = ["email", "openid", "profile"]
  allowed_oauth_flows_user_pool_client = true
  callback_urls                        = ["https://${local.cognito_domain}/oauth2/idpresponse"]
  supported_identity_providers         = ["COGNITO"]
}

resource "random_id" "suffix" {
  byte_length = 24
}

resource "aws_cognito_user_pool_domain" "main" {
  domain                = "auth-${random_id.suffix.hex}"
  user_pool_id          = aws_cognito_user_pool.main.id
  managed_login_version = 2
}

############
## Network
############

locals {
  vpc_cidr = "10.0.0.0/16"
  subnets = {
    public1 = {
      cidr_block        = "10.0.1.0/24"
      availability_zone = "ap-northeast-1a"
    }
    public2 = {
      cidr_block        = "10.0.2.0/24"
      availability_zone = "ap-northeast-1c"
    }
  }
}

resource "aws_vpc" "main" {
  cidr_block = local.vpc_cidr
  tags = {
    Name = "${local.app_name}-vpc"
  }
}

resource "aws_subnet" "public" {
  for_each                = local.subnets
  vpc_id                  = aws_vpc.main.id
  cidr_block              = each.value.cidr_block
  map_public_ip_on_launch = true
  availability_zone       = each.value.availability_zone
  tags = {
    Name = "${local.app_name}-${each.key}"
  }
}

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.main.id
  tags = {
    Name = "${local.app_name}-igw"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }
  tags = {
    Name = "${local.app_name}-public-rtb"
  }
}

resource "aws_route_table_association" "public" {
  for_each       = local.subnets
  subnet_id      = aws_subnet.public[each.key].id
  route_table_id = aws_route_table.public.id
}

############
## ALB
############

resource "aws_security_group" "alb_sg" {
  name        = "alb-sg"
  description = "Allow HTTPS"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${local.app_name}-alb-sg"
  }
}

resource "aws_lb_target_group" "tg" {
  name     = "${local.app_name}-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.main.id

  health_check {
    enabled             = true
    interval            = 30
    path                = "/"
    port                = "traffic-port"
    healthy_threshold   = 3
    unhealthy_threshold = 3
    timeout             = 5
    protocol            = "HTTP"
    matcher             = "200"
  }

  tags = {
    Name = "${local.app_name}-target-group"
  }
}

resource "aws_lb" "alb" {
  name               = "${local.app_name}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb_sg.id]
  subnets            = [for s in aws_subnet.public : s.id]
  tags = {
    Name = "${local.app_name}-alb"
  }
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.alb.arn
  port              = 443
  protocol          = "HTTPS"
  certificate_arn   = aws_acm_certificate_validation.main.certificate_arn

  default_action {
    type = "authenticate-cognito"
    authenticate_cognito {
      user_pool_arn              = aws_cognito_user_pool.main.arn
      user_pool_client_id        = aws_cognito_user_pool_client.main.id
      user_pool_domain           = aws_cognito_user_pool_domain.main.domain
      on_unauthenticated_request = "authenticate"
      scope                      = "openid profile email"
      session_cookie_name        = "AWSELBAuthSessionCookie"
      authentication_request_extra_params = {
        lang = "ja"
      }

    }
    order = 1
  }

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.tg.arn
    order            = 2
  }

  tags = {
    Name = "${local.app_name}-listener"
  }

  depends_on = [aws_acm_certificate_validation.main]
}

resource "aws_lb_target_group_attachment" "https" {
  target_group_arn = aws_lb_target_group.tg.arn
  target_id        = aws_instance.web.id
  port             = 80
}

############
## EC2
############

data "aws_ami" "amazonlinux_2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023*-kernel-6.1-x86_64"]
  }
}

resource "aws_security_group" "ec2_sg" {
  name        = "${local.app_name}-ec2-sg"
  description = "Allow HTTP from ALB"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb_sg.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  tags = {
    Name = "${local.app_name}-ec2-sg"
  }
}

resource "aws_instance" "web" {
  ami                    = data.aws_ami.amazonlinux_2023.id
  instance_type          = "t3.micro"
  subnet_id              = aws_subnet.public["public1"].id
  vpc_security_group_ids = [aws_security_group.ec2_sg.id]
  iam_instance_profile   = aws_iam_instance_profile.main.name
  user_data              = <<EOF
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "Hello World" >> /var/www/html/index.html
EOF

  tags = {
    Name = "${local.app_name}-ec2"
  }
}

resource "aws_iam_role" "main" {
  name = "${local.app_name}-ssm-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      }
    ]
  })

  tags = {
    Name = "${local.app_name}-ssm-cloudwatch-role"
  }
}

resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.main.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "main" {
  name = "SSMAndCloudWatchInstanceProfile"
  role = aws_iam_role.main.name
}

output "ssm_start_session" {
  value = "aws ssm start-session --target ${aws_instance.web.id}"
}

多要素認証 (MFA) 設定

重要な設定は aws_cognito_user_pool 内の以下の設定項目と、
Cognito が SNS を利用するための IAM ロールです。

  • auto_verified_attributes
  • mfa_configuration
  • sms_configuration
  • schema
main.tf
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

resource "random_id" "external_id" {
  byte_length = 16
}

resource "aws_cognito_user_pool" "main" {
  name = "${local.app_name}-user-pool"

  admin_create_user_config {
    allow_admin_create_user_only = true
  }

  password_policy {
    minimum_length    = 8
    require_lowercase = true
    require_numbers   = true
    require_symbols   = true
    require_uppercase = true
  }

  auto_verified_attributes = ["email", "phone_number"]

  # SMS MFAの設定
  mfa_configuration = "ON"

  # SMS設定
  sms_configuration {
    external_id    = random_id.external_id.hex
    sns_caller_arn = aws_iam_role.cognito_sns.arn
    sns_region     = data.aws_region.current.name
  }

  # SMS認証に必要な電話番号属性
  schema {
    attribute_data_type = "String"
    name                = "phone_number"
    required            = true
    mutable             = true
  }
}

# SMS MFA用のIAMロール
resource "aws_iam_role" "cognito_sns" {
  name = "${local.app_name}-cognito-sns-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Service = "cognito-idp.amazonaws.com"
        }
        Action = "sts:AssumeRole"
        Condition = {
          StringEquals = {
            "sts:ExternalId"    = random_id.external_id.hex
            "aws:SourceAccount" = data.aws_caller_identity.current.account_id
          }
          ArnLike = {
            "aws:SourceArn" = "arn:aws:cognito-idp:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:userpool/*"
          }
        }
      }
    ]
  })
}

resource "aws_iam_role_policy" "cognito_sns" {
  name = "${local.app_name}-cognito-sns-policy"
  role = aws_iam_role.cognito_sns.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "sns:Publish"
        ]
        Resource = "*"
      }
    ]
  })
}

E メールメッセージ

最後は E メールメッセージでの MFA 設定です。
この認証方式では Cognito 側で設定するほか、E メールメッセージを送信するために SES を設定する必要があります。

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-settings-mfa-sms-email-message.html

また、E メール設定は以下の 2 種類の方法がありますが、
MFAで使うためには Amazon SES の E メール設定を利用する必要があります。

  • デフォルトの E メール設定
  • Amazon SES の E メール設定

また、SNS と同様に、デフォルトで SES での E メールメッセージの送信はサンドボックスとなっており、
事前に検証済みのメールアドレスにのみメッセージが送信可能です。
今回は検証なのでサンドボックスの方法で進めますが、本番環境で利用する場合はサンドボックスの解除が前提となります。

事前準備

前述の通り、サンドボックス状態だと事前に MFA メールの送信先となるメールアドレスを検証する必要があります。

まず以下コマンドでサンドボックスの送信先の Email アドレスを追加し、

aws ses verify-email-identity \
  --email-address example@example.com \
  --region ap-northeast-1

指定した Email アドレスにメッセージが送信されるので、
メール内の URL をクリックして認証を完了させます。

その後、以下コマンドで確認して、VerificationStatus が Success になっていれば検証完了です。

aws ses get-identity-verification-attributes \
  --identities example@example.com \
  --region ap-northeast-1
{
    "VerificationAttributes": {
        "example@example.com": {
            "VerificationStatus": "Success"
        }
    }
}

コード全体

コード全体
main.tf
provider "aws" {
  region = "ap-northeast-1"
  default_tags {
    tags = {
      app     = local.app_name
      project = "terraform"
    }
  }
}

locals {
  app_name = "cognito-alb-app"
}

############
## Domain
############

variable "domain" {
  type    = string
  default = "example.com"
}

data "aws_route53_zone" "main" {
  name         = var.domain
  private_zone = false
}

locals {
  cognito_domain = "${local.app_name}.${var.domain}"
}

resource "aws_acm_certificate" "main" {
  domain_name = local.cognito_domain

  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "validation" {
  for_each = {
    for dvo in aws_acm_certificate.main.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  zone_id = data.aws_route53_zone.main.id
  name    = each.value.name
  type    = each.value.type
  records = [each.value.record]
  ttl     = 60
}

resource "aws_acm_certificate_validation" "main" {
  certificate_arn         = aws_acm_certificate.main.arn
  validation_record_fqdns = [for record in aws_route53_record.validation : record.fqdn]
}

resource "aws_route53_record" "main" {
  type = "A"

  name    = local.cognito_domain
  zone_id = data.aws_route53_zone.main.id

  alias {
    name                   = aws_lb.alb.dns_name
    zone_id                = aws_lb.alb.zone_id
    evaluate_target_health = true
  }
}

output "site_domain" {
  value = "https://${aws_route53_record.main.fqdn}"
}

############
## SES(Eメール送信用)
############

data "aws_region" "current" {}
data "aws_caller_identity" "current" {}

# SESドメインアイデンティティ
resource "aws_ses_domain_identity" "main" {
  domain = var.domain
}

# SESドメイン検証用のTXTレコード
resource "aws_route53_record" "ses_verification" {
  zone_id = data.aws_route53_zone.main.zone_id
  name    = "_amazonses.${var.domain}"
  type    = "TXT"
  records = [aws_ses_domain_identity.main.verification_token]
  ttl     = 600
}

# SESドメイン検証の完了を待機
resource "aws_ses_domain_identity_verification" "main" {
  domain     = aws_ses_domain_identity.main.id
  depends_on = [aws_route53_record.ses_verification]
}

# SESでDKIMを有効化
resource "aws_ses_domain_dkim" "main" {
  domain = var.domain
}

# Route53でDKIMレコードを設定
resource "aws_route53_record" "dkim" {
  count   = 3
  zone_id = data.aws_route53_zone.main.zone_id
  name    = "${aws_ses_domain_dkim.main.dkim_tokens[count.index]}._domainkey"
  type    = "CNAME"
  records = ["${aws_ses_domain_dkim.main.dkim_tokens[count.index]}.dkim.amazonses.com"]
  ttl     = 60
}

# SES設定セット
resource "aws_sesv2_configuration_set" "cognito" {
  configuration_set_name = "${local.app_name}-cognito-config-set"

  delivery_options {
    tls_policy = "REQUIRE"
  }

  sending_options {
    sending_enabled = true
  }

  reputation_options {
    reputation_metrics_enabled = true
  }

  tags = {
    Name = "${local.app_name}-cognito-ses-config"
  }
}

############
## Cognito(MFA対応版)
############

variable "receive_email" {
  default = "example_receivee@example.com"
}

locals {
  no_reply_email = "no-reply@${var.domain}"
}

data "aws_ses_email_identity" "receive_email" {
  email = var.receive_email
}

locals {
  users = {
    test_user = {
      name     = "TestUser1"
      email    = var.receive_email
      password = "Temp123!"
    },
    test_user2 = {
      name     = "TestUser2"
      email    = var.receive_email
      password = "Temp123!"
    },
    test_user3 = {
      name     = "TestUser3"
      email    = var.receive_email
      password = "Temp123!"
    }
  }
}

resource "aws_cognito_user_pool" "main" {
  name = "${local.app_name}-user-pool"

  admin_create_user_config {
    allow_admin_create_user_only = true
  }

  password_policy {
    minimum_length    = 8
    require_lowercase = true
    require_numbers   = true
    require_symbols   = true
    require_uppercase = true
  }

  auto_verified_attributes = ["email"]

  mfa_configuration = "ON"

  # MFAでEmailを設定
  email_mfa_configuration {
    message = "Your verification code is {####}"
    subject = "Your verification code"
  }

  # Email MFA設定 - SESを使用
  email_configuration {
    email_sending_account  = "DEVELOPER"
    source_arn             = aws_ses_domain_identity.main.arn
    from_email_address     = local.no_reply_email
    configuration_set      = aws_sesv2_configuration_set.cognito.configuration_set_name
    reply_to_email_address = local.no_reply_email
  }

  tags = {
    Name = "${local.app_name}-user-pool"
  }

  # SESリソースが作成されてから実行
  depends_on = [aws_ses_domain_identity_verification.main]
}

resource "aws_cognito_user" "users" {
  for_each = local.users

  user_pool_id = aws_cognito_user_pool.main.id
  username     = each.value.name

  attributes = {
    email          = each.value.email
    email_verified = true
    name           = each.value.name
  }

  temporary_password = each.value.password
}

resource "aws_cognito_user_pool_client" "main" {
  name                                 = "${local.app_name}-user-pool-client"
  user_pool_id                         = aws_cognito_user_pool.main.id
  generate_secret                      = true
  allowed_oauth_flows                  = ["code"]
  allowed_oauth_scopes                 = ["email", "openid", "profile"]
  allowed_oauth_flows_user_pool_client = true
  callback_urls                        = ["https://${local.cognito_domain}/oauth2/idpresponse"]
  supported_identity_providers         = ["COGNITO"]
}

resource "random_id" "suffix" {
  byte_length = 24
}

resource "aws_cognito_user_pool_domain" "main" {
  domain                = "auth-${random_id.suffix.hex}"
  user_pool_id          = aws_cognito_user_pool.main.id
  managed_login_version = 2
}

############
## Network
############

locals {
  vpc_cidr = "10.0.0.0/16"
  subnets = {
    public1 = {
      cidr_block        = "10.0.1.0/24"
      availability_zone = "ap-northeast-1a"
    }
    public2 = {
      cidr_block        = "10.0.2.0/24"
      availability_zone = "ap-northeast-1c"
    }
  }
}

resource "aws_vpc" "main" {
  cidr_block = local.vpc_cidr
  tags = {
    Name = "${local.app_name}-vpc"
  }
}

resource "aws_subnet" "public" {
  for_each                = local.subnets
  vpc_id                  = aws_vpc.main.id
  cidr_block              = each.value.cidr_block
  map_public_ip_on_launch = true
  availability_zone       = each.value.availability_zone
  tags = {
    Name = "${local.app_name}-${each.key}"
  }
}

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.main.id
  tags = {
    Name = "${local.app_name}-igw"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }
  tags = {
    Name = "${local.app_name}-public-rtb"
  }
}

resource "aws_route_table_association" "public" {
  for_each       = local.subnets
  subnet_id      = aws_subnet.public[each.key].id
  route_table_id = aws_route_table.public.id
}

############
## ALB
############

resource "aws_security_group" "alb_sg" {
  name        = "alb-sg"
  description = "Allow HTTPS"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${local.app_name}-alb-sg"
  }
}

resource "aws_lb_target_group" "tg" {
  name     = "${local.app_name}-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.main.id

  health_check {
    enabled             = true
    interval            = 30
    path                = "/"
    port                = "traffic-port"
    healthy_threshold   = 3
    unhealthy_threshold = 3
    timeout             = 5
    protocol            = "HTTP"
    matcher             = "200"
  }

  tags = {
    Name = "${local.app_name}-target-group"
  }
}

resource "aws_lb" "alb" {
  name               = "${local.app_name}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb_sg.id]
  subnets            = [for s in aws_subnet.public : s.id]
  tags = {
    Name = "${local.app_name}-alb"
  }
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.alb.arn
  port              = 443
  protocol          = "HTTPS"
  certificate_arn   = aws_acm_certificate_validation.main.certificate_arn

  default_action {
    type = "authenticate-cognito"
    authenticate_cognito {
      user_pool_arn              = aws_cognito_user_pool.main.arn
      user_pool_client_id        = aws_cognito_user_pool_client.main.id
      user_pool_domain           = aws_cognito_user_pool_domain.main.domain
      on_unauthenticated_request = "authenticate"
      scope                      = "openid profile email"
      session_cookie_name        = "AWSELBAuthSessionCookie"
      authentication_request_extra_params = {
        lang = "ja"
      }

    }
    order = 1
  }

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.tg.arn
    order            = 2
  }

  tags = {
    Name = "${local.app_name}-listener"
  }

  depends_on = [aws_acm_certificate_validation.main]
}

resource "aws_lb_target_group_attachment" "https" {
  target_group_arn = aws_lb_target_group.tg.arn
  target_id        = aws_instance.web.id
  port             = 80
}

############
## EC2
############

data "aws_ami" "amazonlinux_2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023*-kernel-6.1-x86_64"]
  }
}

resource "aws_security_group" "ec2_sg" {
  name        = "${local.app_name}-ec2-sg"
  description = "Allow HTTP from ALB"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb_sg.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  tags = {
    Name = "${local.app_name}-ec2-sg"
  }
}

resource "aws_instance" "web" {
  ami                    = data.aws_ami.amazonlinux_2023.id
  instance_type          = "t3.micro"
  subnet_id              = aws_subnet.public["public1"].id
  vpc_security_group_ids = [aws_security_group.ec2_sg.id]
  iam_instance_profile   = aws_iam_instance_profile.main.name
  user_data              = <<EOF
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "Hello World" >> /var/www/html/index.html
EOF

  tags = {
    Name = "${local.app_name}-ec2"
  }
}

resource "aws_iam_role" "main" {
  name = "${local.app_name}-ssm-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      }
    ]
  })

  tags = {
    Name = "${local.app_name}-ssm-cloudwatch-role"
  }
}

resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.main.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "main" {
  name = "SSMAndCloudWatchInstanceProfile"
  role = aws_iam_role.main.name
}

output "ssm_start_session" {
  value = "aws ssm start-session --target ${aws_instance.web.id}"
}

SES 設定

重要な設定は以下の SES 関連リソースです。
email_mfa_configuration は E メール送信時の MFA 設定を行い、
email_configuration でメール送信設定を行います。

main.tf
# SESドメインアイデンティティ
resource "aws_ses_domain_identity" "main" {
  domain = var.domain
}

# SESドメイン検証用のTXTレコード
resource "aws_route53_record" "ses_verification" {
  zone_id = data.aws_route53_zone.main.zone_id
  name    = "_amazonses.${var.domain}"
  type    = "TXT"
  records = [aws_ses_domain_identity.main.verification_token]
  ttl     = 600
}

# SESドメイン検証の完了を待機
resource "aws_ses_domain_identity_verification" "main" {
  domain     = aws_ses_domain_identity.main.id
  depends_on = [aws_route53_record.ses_verification]
}

# SESでDKIMを有効化
resource "aws_ses_domain_dkim" "main" {
  domain = var.domain
}

# Route53でDKIMレコードを設定
resource "aws_route53_record" "dkim" {
  count   = 3
  zone_id = data.aws_route53_zone.main.zone_id
  name    = "${aws_ses_domain_dkim.main.dkim_tokens[count.index]}._domainkey"
  type    = "CNAME"
  records = ["${aws_ses_domain_dkim.main.dkim_tokens[count.index]}.dkim.amazonses.com"]
  ttl     = 60
}

# SES設定セット
resource "aws_sesv2_configuration_set" "cognito" {
  configuration_set_name = "${local.app_name}-cognito-config-set"

  delivery_options {
    tls_policy = "REQUIRE"
  }

  sending_options {
    sending_enabled = true
  }

  reputation_options {
    reputation_metrics_enabled = true
  }

  tags = {
    Name = "${local.app_name}-cognito-ses-config"
  }
}

多要素認証 (MFA) 設定

重要な設定は aws_cognito_user_pool 内の以下の設定項目です。

  • mfa_configuration
  • email_mfa_configuration
  • email_configuration
main.tf
resource "aws_cognito_user_pool" "main" {
  name = "${local.app_name}-user-pool"

  admin_create_user_config {
    allow_admin_create_user_only = true
  }

  password_policy {
    minimum_length    = 8
    require_lowercase = true
    require_numbers   = true
    require_symbols   = true
    require_uppercase = true
  }

  auto_verified_attributes = ["email"]

  mfa_configuration = "ON"

  # MFAでEmailを設定
  email_mfa_configuration {
    message = "Your verification code is {####}"
    subject = "Your verification code"
  }

  # Email MFA設定 - SESを使用
  email_configuration {
    email_sending_account  = "DEVELOPER"
    source_arn             = aws_ses_domain_identity.main.arn
    from_email_address     = local.no_reply_email
    configuration_set      = aws_sesv2_configuration_set.cognito.configuration_set_name
    reply_to_email_address = local.no_reply_email
  }

  tags = {
    Name = "${local.app_name}-user-pool"
  }

  # SESリソースが作成されてから実行
  depends_on = [aws_ses_domain_identity_verification.main]
}

さいごに

以上、Cognito の多要素認証 (MFA) を Terraformでまとめて設定してみました。

今回は TOTP ソフトウェアトークン、SMS メッセージ、E メールメッセージの 3種類の MFA 方式をそれぞれ実装しましたが、
実際のプロダクションでは用途や要件に応じて適切な方式を選択することが重要です。

特に SMS や E メール MFA については、本記事ではサンドボックス環境での検証方法を紹介しましたが、本番運用では事前にサンドボックスの解除申請を行う必要があります。
また、セキュリティや利便性の観点から、TOTP方式も併用することを検討してみてください。
(個人的には具体的な要件が特にない場合は、TOTP方式一択な気がします)

この記事が皆様の AWS Cognitoを活用したアプリケーション開発の参考になれば幸いです。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.