Cognitoの多要素認証(MFA)をTerraformでまとめて設定してみた。
はじめに
皆様こんにちは、あかいけです。
突然ですが AWS Cognito では以下の種類の多要素認証 (MFA) を設定できます。
- TOTP ソフトウェアトークン MFA
- SMS メッセージ MFA
- E メールメッセージ MFA
いずれの方法も自前で実装するよりかなり簡単に実装できますが、
それぞれ設定方法が異なっており、一通り試してみると意外と面倒でした。
というわけで、
今回は Terraform で多要素認証 (MFA) をまとめて設定してみました。
なお構成自体は以下の記事と同じで、これに多要素認証の設定を追加しています。
構成図
今回は ALB + Cognito の構成です。
またパブリック証明書の発行やドメインの登録も Terraform 内に含めています。
事前準備
事前に準備が必要となるのは、ドメインだけです。
そのため、 Route53 でドメインの取得 + デフォルトで作成されるホストゾーン があれば OK です。
TOTP ソフトウェアトークン MFA
まずは TOTP ソフトウェアトークン MFAでの MFA 設定です。
この認証方式では Cognito 側で設定するほか、クライアント側のデバイスに TOTP アプリのインストールが必要となります。
コード全体
コード全体
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
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 を設定する必要があります。
また、デフォルトで 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"
}
]
}
コード全体
コード全体
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
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 を設定する必要があります。
また、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"
}
}
}
コード全体
コード全体
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 でメール送信設定を行います。
# 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
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を活用したアプリケーション開発の参考になれば幸いです。