ALB + Cognito をTerraformでまとめて作ってみた。
はじめに
皆様こんにちは、あかいけです。
最近 AWS Cognito に入門して、
スクラッチで作る場合に比べて簡単に認証機能を実装できることに感動しました。
とはいえ Cognito の設定、ドメイン関連の設定、紐付け先の設定 (Cloudfront、ALB、API Gateway…etc) などなど、意外と関連するサービスが多く、設定に手間取ってしまうこともあるのではないでしょうか?
というわけで一括でデプロイする方法を調べてみたのですが意外と紹介されていなかったので、
今回は Terraform でまとめてデプロイできるようにしたものを共有します。
構成図
今回は ALB + Cognito の構成です。
またパブリック証明書の発行やドメインの登録も Terraform 内に含めています。
事前準備
事前に準備が必要となるのは、ドメインだけです。
そのため、 Route53 でドメインの取得 + デフォルトで作成されるホストゾーン があれば OK です。
Terraform
デプロイ方法は簡単で、
variable "domain"
の値を利用する Route53 のドメイン名に置き換えて、apply するだけです。
またALBに紐づけるドメイン名はcognito-alb-app.<var.domain>
の形式にしているので、ここはお好みで変更してください。
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 = "TestUser"
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"]
}
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}"
}
terraform apply;
デプロイが完了したら、マネジメントコンソールのCognitoのマネージドログインの画面に移動し、
「スタイルを作成」をクリックし、作成したアプリケーションクライアントを指定してスタイルを作成します。
ここまで完了すると、Cognitoで認証できるようになります。
なお最後のスタイルを作成する作業については、おそらく現在Terraform側で対応していないため手動で作業しています。
ただし以下のIssueでこの機能のリクエストが出ているっぽいので、
Terraform側で定義できるようになる日も遠くはなさそうです。
設定値の解説
このままだとあまりに内容が薄いので、重要となる設定をいくつか解説します。
Domain
まずはパブリック証明書検証用のレコードです。
ぱっと見だと謎の呪文にしか見えませんが、パブリック証明書作成時に指定したドメイン検証用の値を参照して、それをCNAMEレコードとして登録しています。
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
}
次に以下のリソースはパブリック証明書の検証が完了したことを表すリソースです。
こちらで作成したCNAMEレコードを参照して、正常に検証が完了したか確認しています。
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]
}
Cognito
ユーザープールの設定では、認証方式に関する全般を設定しています。
今回は利用者側で勝手に新規ユーザーを作成できないようにしたかったため、
allow_admin_create_user_only = true
とすることで管理者側でのみ作成できるようにしています。
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"]
}
次にアプリケーションクライアントの設定では、callback_urls
が重要な設定で、
名前の通り認証が成功した後にリダイレクトするURLを指定します。
またCognitoの仕様上、末尾に/oauth2/idpresponse
を付与する必要があるため、
実際に指定するのは https:// + ドメイン名 + /oauth2/idpresponse
の形式となります。
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"]
}
最後にユーザープールドメインの設定では、managed_login_version
を指定しています。
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
}
この設定はデフォルトだとバージョン1になるのですが、その場合は以下のような奥ゆかしいデフォルトのログイン画面になります。
ALB
ALBについてはリスナーの設定が重要です。
まず認証方法としてCognitoを指定して、オプションとしてlang = "ja"
を指定しています。
これによりログインページの言語を日本語に設定できます。
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]
}
さいごに
以上、ALB + Cognito をTerraformでまとめて作る方法でした。
個人的にCognitoって触る機会がないとまず触らないサービスの代表だと思います。
なので触ったことのない方も、これを機に触ってみてはいかがでしょうか。