diff --git a/apigateway.tf b/apigateway.tf index 296f944..f9d6525 100644 --- a/apigateway.tf +++ b/apigateway.tf @@ -1,7 +1,7 @@ resource "aws_api_gateway_rest_api" "samlpost" { provider = aws.iam-security-account - name = "SAMLPostExample" + name = "LoginAppSAML" description = "Terraform Serverless Application Example" tags = local.common_tags @@ -67,7 +67,7 @@ resource "aws_api_gateway_deployment" "samlpost" { rest_api_id = aws_api_gateway_rest_api.samlpost.id // @todo change value below to something like "saml" - stage_name = "test" + stage_name = "api" } diff --git a/cloudfront.tf b/cloudfront.tf index e5f4fd5..71bac47 100644 --- a/cloudfront.tf +++ b/cloudfront.tf @@ -2,6 +2,58 @@ locals { cf_origin_id = "api_gateway_saml" } +data "aws_route53_zone" "this" { + provider = aws.perimeter-account + name = var.domain_name +} + +resource "aws_route53_record" "login_app" { + provider = aws.perimeter-account + zone_id = data.aws_route53_zone.this.zone_id + name = "login.${var.domain_name}" + type = "A" + + alias { + name = aws_cloudfront_distribution.geofencing.domain_name + zone_id = aws_cloudfront_distribution.geofencing.hosted_zone_id + evaluate_target_health = false + } +} + +resource "aws_acm_certificate" "this" { + provider = aws.iam-security-account-us-east-1 + domain_name = "login.${var.domain_name}" + validation_method = "DNS" + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_route53_record" "this_acm" { + provider = aws.perimeter-account + for_each = { + for dvo in aws_acm_certificate.this.domain_validation_options : dvo.domain_name => { + name = dvo.resource_record_name + record = dvo.resource_record_value + type = dvo.resource_record_type + } + } + + allow_overwrite = true + name = each.value.name + records = [each.value.record] + ttl = 60 + type = each.value.type + zone_id = data.aws_route53_zone.this.zone_id +} + +resource "aws_acm_certificate_validation" "this" { + provider = aws.iam-security-account-us-east-1 + certificate_arn = aws_acm_certificate.this.arn + validation_record_fqdns = [for record in aws_route53_record.this_acm : record.fqdn] +} + resource "aws_cloudfront_distribution" "geofencing" { provider = aws.iam-security-account @@ -62,6 +114,9 @@ resource "aws_cloudfront_distribution" "geofencing" { max_ttl = 86400 } + aliases = ["login.${var.domain_name}"] + default_root_object = "${aws_api_gateway_deployment.samlpost.stage_name}/redirect" + price_class = "PriceClass_100" restrictions { @@ -75,10 +130,17 @@ resource "aws_cloudfront_distribution" "geofencing" { tags = local.common_tags viewer_certificate { - cloudfront_default_certificate = true + cloudfront_default_certificate = false + acm_certificate_arn = aws_acm_certificate_validation.this.certificate_arn + minimum_protocol_version = "TLSv1.2_2021" + ssl_support_method = "sni-only" } } output "cloudfront_url" { value = "https://${aws_cloudfront_distribution.geofencing.domain_name}/${aws_api_gateway_deployment.samlpost.stage_name}" } + +output "login_domain_name" { + value = "https://login.${var.domain_name}/${aws_api_gateway_deployment.samlpost.stage_name}" +} \ No newline at end of file diff --git a/lambda.tf b/lambda.tf index bba224e..91c1182 100644 --- a/lambda.tf +++ b/lambda.tf @@ -7,7 +7,7 @@ data "archive_file" "lambda_zip" { resource "aws_lambda_function" "samlpost" { provider = aws.iam-security-account - function_name = "SAMLPostExample-${var.resource_name_suffix}" + function_name = "${var.lambda_name}-${var.resource_name_suffix}" filename = data.archive_file.lambda_zip.output_path source_code_hash = data.archive_file.lambda_zip.output_base64sha256 @@ -21,10 +21,10 @@ resource "aws_lambda_function" "samlpost" { environment { variables = { - samlReadRole = "arn:aws:iam::${local.master_account_id}:saml-provider/${var.keycloak_saml_name},arn:aws:iam::${local.master_account_id}:role/${local.saml_read_role_name}", - kc_base_url = var.kc_base_url, - kc_realm = var.kc_realm, - kc_terraform_auth_client_id = var.kc_terraform_auth_client_id, + samlReadRole = "arn:aws:iam::${local.master_account_id}:saml-provider/${var.keycloak_saml_name},arn:aws:iam::${local.master_account_id}:role/${local.saml_read_role_name}", + kc_base_url = var.kc_base_url, + kc_realm = var.kc_realm, + kc_terraform_auth_client_id = var.kc_terraform_auth_client_id, kc_terraform_auth_client_secret = var.kc_terraform_auth_client_secret } } @@ -35,7 +35,7 @@ resource "aws_lambda_function" "samlpost" { resource "aws_iam_role" "lambda_exec" { provider = aws.iam-security-account - name = "serverless_saml_lambda-${var.resource_name_suffix}" + name = "${var.lambda_name}-${var.resource_name_suffix}" assume_role_policy = < { - organizations.listTagsForResource({ResourceId: account}, function (err, data){ - if (err) { - reject(err); - } - let accountTags = {}; - accountTags.accountId = account; - accountTags.tags = data.Tags; - organizations.describeAccount({AccountId: account}, function (e, d) { - if (e) { - console.log(e); - reject(e); - } - - accountTags.accountName = d.Account.Name; - resolve(accountTags); - }); - }); - }); - })) - }; - - getTagsForAccounts(accounts, organizations) - .then((values) =>{ - let returnVal = {}; - for (const val of values) { - returnVal[val.accountId] = val; - } - - var response = { - statusCode: 200, - body: JSON.stringify(returnVal) - }; - - callback(null, response); - }); - } - }); + callback(null, response) + } + + else if (event.path == "/accounttags" && event.httpMethod == "POST") { + if (event.body) { + + let body = JSON.parse(event.body); + + let buff = Buffer.from(body.samlResponse, "base64"); + let decodedsamlResponse = buff.toString("utf-8"); + // check if azure ad idir user is logged in + if (checkSAMLForAzureIDP(decodedsamlResponse)) { + transferKeyCloakGroups(decodedsamlResponse) + } + + let accounts = parseSAMLResponse(decodedsamlResponse); + let saml_read_role = process.env.samlReadRole.split(","); + let roleArn = saml_read_role[1]; + let sts = new AWS.STS(); + + let params = { + DurationSeconds: 900, + RoleArn: roleArn, + RoleSessionName: "AWSLoginAppOrgRead" + }; + + sts.assumeRole(params, function (err, data) { + if (err) { + console.log(err, err.stack); + var response = { + statusCode: 500, + body: JSON.stringify(err) + }; + + callback(null, response); + } else { + let organizations = new AWS.Organizations({ + accessKeyId: data.Credentials.AccessKeyId, + secretAccessKey: data.Credentials.SecretAccessKey, + sessionToken: data.Credentials.SessionToken, + region: "us-east-1" + }); + + var getTagsForAccounts = function getTagsForAccounts(accountData, orgClient) { + return Promise.all(Object.keys(accountData).map(function (account) { + return new Promise((resolve, reject) => { + organizations.listTagsForResource({ ResourceId: account }, function (err, data) { + if (err) { + reject(err); + } + let accountTags = {}; + accountTags.accountId = account; + accountTags.tags = data.Tags; + organizations.describeAccount({ AccountId: account }, function (e, d) { + if (e) { + console.log(e); + reject(e); + } + + accountTags.accountName = d.Account.Name; + resolve(accountTags); + }); + }); + }); + })) + }; + + getTagsForAccounts(accounts, organizations) + .then((values) => { + let returnVal = {}; + for (const val of values) { + returnVal[val.accountId] = val; + } + + var response = { + statusCode: 200, + body: JSON.stringify(returnVal) + }; + + callback(null, response); + }); } - } - else if (event.path == "/consolelogin" && event.httpMethod == "POST") { - if (event.body) { + }); - let body = JSON.parse(event.body); + } + } + else if (event.path == "/consolelogin" && event.httpMethod == "POST") { - let principalArn = body.PrincipalArn; - let roleArn = body.RoleArn; - let samlResponse = body.SAMLAssertion; - let duration = body.DurationSeconds; + if (event.body) { - let sts = new AWS.STS(); + let body = JSON.parse(event.body); - let params = { - DurationSeconds: duration, - PrincipalArn: principalArn, - RoleArn: roleArn, - SAMLAssertion: samlResponse - }; + let principalArn = body.PrincipalArn; + let roleArn = body.RoleArn; + let samlResponse = body.SAMLAssertion; + let duration = body.DurationSeconds; - sts.assumeRoleWithSAML(params, function (err, data) { - if (err) { - console.log(err, err.stack); - } else { + let sts = new AWS.STS(); - let accessKeyId = data.Credentials.AccessKeyId; - let secretKey = data.Credentials.SecretAccessKey; - let sessionToken = data.Credentials.SessionToken; - let issuer = data.Issuer; + let params = { + DurationSeconds: duration, + PrincipalArn: principalArn, + RoleArn: roleArn, + SAMLAssertion: samlResponse + }; - let st = - { - "sessionId": accessKeyId, - "sessionKey": secretKey, - "sessionToken": sessionToken - } + sts.assumeRoleWithSAML(params, function (err, data) { + if (err) { + console.log(err, err.stack); + } else { - let sessionTokenString = encodeURIComponent(JSON.stringify(st)); + let accessKeyId = data.Credentials.AccessKeyId; + let secretKey = data.Credentials.SecretAccessKey; + let sessionToken = data.Credentials.SessionToken; + let issuer = data.Issuer; - let requestParams = "?Action=getSigninToken"; - requestParams += `&SessionDuration=${duration}`; - requestParams += `&Session=${sessionTokenString}`; + let st = + { + "sessionId": accessKeyId, + "sessionKey": secretKey, + "sessionToken": sessionToken + } - let requestUrl = "https://signin.aws.amazon.com/federation" + requestParams; + let sessionTokenString = encodeURIComponent(JSON.stringify(st)); - https.get(requestUrl, (resp) => { - let data = ''; + let requestParams = "?Action=getSigninToken"; + requestParams += `&SessionDuration=${duration}`; + requestParams += `&Session=${sessionTokenString}`; - // A chunk of data has been recieved. - resp.on('data', (chunk) => { - data += chunk; - }); + let requestUrl = "https://signin.aws.amazon.com/federation" + requestParams; - // The whole response has been received. Print out the result. - resp.on('end', () => { - const signInToken = JSON.parse(data).SigninToken; + https.get(requestUrl, (resp) => { + let data = ''; - requestParams = "?Action=login"; - requestParams += `&Issuer=${issuer}`; - requestParams += `&Destination=https://console.aws.amazon.com/console/home?region=ca-central-1`; - requestParams += `&SigninToken=${signInToken}` + // A chunk of data has been recieved. + resp.on('data', (chunk) => { + data += chunk; + }); - requestUrl = "https://signin.aws.amazon.com/federation" + requestParams; + // The whole response has been received. Print out the result. + resp.on('end', () => { + const signInToken = JSON.parse(data).SigninToken; - var response = { - statusCode: 200, - body: JSON.stringify({ Location: requestUrl }) - }; + requestParams = "?Action=login"; + requestParams += `&Issuer=${issuer}`; + requestParams += `&Destination=https://console.aws.amazon.com/console/home?region=ca-central-1`; + requestParams += `&SigninToken=${signInToken}` - callback(null, response); + requestUrl = "https://signin.aws.amazon.com/federation" + requestParams; - }); + var response = { + statusCode: 200, + body: JSON.stringify({ Location: requestUrl }) + }; - }).on("error", (err) => { - console.log("Error: " + err.message); - }); + callback(null, response); - } }); - } - } - else { - var response = { - statusCode: 200, - body: "Unknown Method" + }).on("error", (err) => { + console.log("Error: " + err.message); + }); + } - callback(null, response) + }); + } + + } + else { + var response = { + statusCode: 200, + body: "Unknown Method" } + callback(null, response) + } } function parseSAMLResponse(samlResponse) { - //let capturingRegex = new RegExp(">(?arn:aws:iam::\\d+:saml-provider/\\S+),(?arn:aws::iam::(?\\d+):role/(?\\w+))<"); - let capturingRegex = new RegExp(">(arn:aws:iam::\\d+:saml-provider/[a-zA-Z0-9-_@=+.]+),(arn:aws:iam::(\\d+):role/([a-zA-Z0-9-_@=+.]+))<", "gi"); - ///>(arn:aws:iam::\d+:saml-provider\/\S+),(arn:aws:iam::(\d+):role\/(\w+))(?arn:aws:iam::\\d+:saml-provider/\\S+),(?arn:aws::iam::(?\\d+):role/(?\\w+))<"); + let capturingRegex = new RegExp(">(arn:aws:iam::\\d+:saml-provider/[a-zA-Z0-9-_@=+.]+),(arn:aws:iam::(\\d+):role/([a-zA-Z0-9-_@=+.]+))<", "gi"); + ///>(arn:aws:iam::\d+:saml-provider\/\S+),(arn:aws:iam::(\d+):role\/(\w+))\\S+@azureidir<", "gm"); let matches = saml_response.match(capturing_regex); let email = matches[0].replace('>', '').replace('@azureidir<', '') @@ -269,8 +279,8 @@ function parseSAMLForEmail(saml_response){ return email; } -function makeHttpRequest(options, post_data){ - return new Promise(function (resolve, reject){ +function makeHttpRequest(options, post_data) { + return new Promise(function (resolve, reject) { let req = https.request(options, function (res) { if (res.statusCode < 200 || res.statusCode >= 300) { return reject(new Error('statusCode=' + res.statusCode)); @@ -284,7 +294,7 @@ function makeHttpRequest(options, post_data){ resolve(body); }); }); - req.on('error', function(error){ + req.on('error', function (error) { reject(error); }); req.write(post_data); @@ -292,7 +302,7 @@ function makeHttpRequest(options, post_data){ }); } -async function getKeyCloakToken(){ +async function getKeyCloakToken() { let options = { 'method': 'POST', 'hostname': kc_base_url, @@ -310,7 +320,7 @@ async function getKeyCloakToken(){ return makeHttpRequest(options, post_data); } -async function getUsersWithEmail(headers, target_user_email){ +async function getUsersWithEmail(headers, target_user_email) { let options = { 'method': 'GET', 'hostname': kc_base_url, @@ -319,10 +329,10 @@ async function getUsersWithEmail(headers, target_user_email){ 'maxRedirects': 20 }; let post_data = qs.stringify({}); - return makeHttpRequest(options, post_data); + return makeHttpRequest(options, post_data); } -async function getSiteminderUserGroups(headers, siteminder_user_id){ +async function getSiteminderUserGroups(headers, siteminder_user_id) { let options = { 'method': 'GET', 'hostname': kc_base_url, @@ -331,10 +341,10 @@ async function getSiteminderUserGroups(headers, siteminder_user_id){ 'maxRedirects': 20 }; let post_data = qs.stringify({}); - return makeHttpRequest(options, post_data); + return makeHttpRequest(options, post_data); } -async function putAzureADUserGroup(headers, azure_ad_user_id, group_id){ +async function putAzureADUserGroup(headers, azure_ad_user_id, group_id) { let options = { 'method': 'PUT', 'hostname': kc_base_url, @@ -343,7 +353,7 @@ async function putAzureADUserGroup(headers, azure_ad_user_id, group_id){ 'maxRedirects': 20 }; let post_data = qs.stringify({}); - return makeHttpRequest(options, post_data); + return makeHttpRequest(options, post_data); } async function disableSiteminderUser(headers, siteminder_user_id) { @@ -360,10 +370,10 @@ async function disableSiteminderUser(headers, siteminder_user_id) { var post_data = JSON.stringify({ "enabled": false }); - return makeHttpRequest(options, post_data); + return makeHttpRequest(options, post_data); } -async function transferKeyCloakGroups(saml_response){ +async function transferKeyCloakGroups(saml_response) { console.log("In transferKeyCloakGroups(), transferring groups from Sitminder IDP user to Azure AD IDP user"); let token_response = await getKeyCloakToken(); diff --git a/main.tf b/main.tf index 433ea2c..82a93bd 100644 --- a/main.tf +++ b/main.tf @@ -19,6 +19,26 @@ provider "aws" { } } +provider "aws" { + region = "us-east-1" + alias = "iam-security-account-us-east-1" + + assume_role { + role_arn = "arn:aws:iam::${local.iam_security_account.id}:role/AWSCloudFormationStackSetExecutionRole" + session_name = "slz-terraform-automation" + } +} + +provider "aws" { + region = "ca-central-1" + alias = "perimeter-account" + + assume_role { + role_arn = "arn:aws:iam::${local.perimeter_account.id}:role/AWSCloudFormationStackSetExecutionRole" + session_name = "slz-terraform-automation" + } +} + module "lz_info" { source = "github.com/BCDevOps/terraform-aws-sea-organization-info" providers = { @@ -32,9 +52,10 @@ data "aws_caller_identity" "master_account_caller" { locals { - core_accounts = { for account in module.lz_info.core_accounts : account.name => account } - iam_security_account = local.core_accounts["iam-security"] - saml_destination_url = "https://${aws_cloudfront_distribution.geofencing.domain_name}/${aws_api_gateway_deployment.samlpost.stage_name}" + core_accounts = { for account in module.lz_info.core_accounts : account.name => account } + iam_security_account = local.core_accounts["iam-security"] + perimeter_account = local.core_accounts["Perimeter"] + saml_destination_url = "https://login.${var.domain_name}/${aws_api_gateway_deployment.samlpost.stage_name}" //Put all common tags here @@ -60,14 +81,9 @@ resource "aws_iam_role" "saml_read_role" { { "Effect": "Allow", "Principal": { - "Federated": "arn:aws:iam::${local.master_account_id}:saml-provider/${var.keycloak_saml_name}" + "AWS": "arn:aws:iam::${local.iam_security_account.id}:role/${var.lambda_name}-${var.resource_name_suffix}" }, - "Action": "sts:AssumeRoleWithSAML", - "Condition": { - "StringEquals": { - "SAML:aud": "${local.saml_destination_url}" - } - } + "Action": "sts:AssumeRole" } ] } @@ -88,7 +104,7 @@ resource "aws_iam_role_policy" "saml_read_role_policy" { "Effect": "Allow", "Action": [ "organizations:ListTagsForResource", - "organizations:DescribeAccount" + "organizations:DescribeAccount" ], "Resource": [ "*" @@ -97,4 +113,4 @@ resource "aws_iam_role_policy" "saml_read_role_policy" { ] } EOF -} +} \ No newline at end of file diff --git a/variables.tf b/variables.tf index f003c97..2af73d5 100644 --- a/variables.tf +++ b/variables.tf @@ -1,3 +1,9 @@ +variable "lambda_name" { + description = "The name of the created lambda function" + type = string + default = "LoginApp_saml_lambda" +} + variable "keycloak_saml_name" { description = "The name of an (existing) Keycloak IDP in the root account that will be referenced when assuming the role used to read account metadata. It generally will be suffixed with an alphanumeric discriminator (corresponding to a KeyCloak realm) since more than one IDP may exist." default = "BCGovKeyCloak" @@ -31,4 +37,9 @@ variable "kc_terraform_auth_client_secret" { description = "The authentication secret of the keycloak client used for terraform automation" type = string default = "" -} \ No newline at end of file +} + +variable "domain_name" { + description = "Domain name of the login app" + type = string +}