Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

AWS Transfer 패밀리로 SFTP 구성하기 #2

AWS Transfer 패밀리로 SFTP 구성하기 #1에서 이어진 글이다.


Custom AWS Transfer for SFTP 구성

AWS Transfer 서버에서 제공하는 인증 외에 다른 인증을 사용하려면 Custom AWS Transfer 서버로 구성해야 한다. Custom 서버를 이용하면 API Gateway와 연결해서 구성해야 한다.

사용자가 SFTP 접속을 요청하면 Transfer 서버가 API Gateway에 이 인증 정보를 전달하고 API Gateway에서 적절한 응답을 돌려주면 SFTP에 접속하게 할 수 있다. 그래서 API Gateway에 Lambda를 연결해서 Secrets Manger 혹은 Cognito와 연결해서 로그인하게 할 수 있다. 이를 Identity Provider라고 부른다.

FTP Client - AWS SFTP - API Gateway - Secret Manger로 이어지는 흐름

위 흐름은 Enable password authentication for AWS Transfer for SFTP using AWS Secrets Manager에서 가져온 이미지다. 요청의 처리가 위와 같은 순서로 이루어지고 Lambda를 연결했으므로 Lambda 코드에 따라 원하는 다른 Identity Provider를 얼마든지 연결할 수 있다.

나는 간단히 ID/Password로 로그인하게 하고 싶어서 Enable password authentication for AWS Transfer for SFTP using AWS Secrets Manager
를 참고해서 Lambda와 Secrets Manager를 사용했다.

일단 1편처럼 S3 버킷이 있다고 해보자.

resource "aws_s3_bucket" "stfp_sample" {
  bucket = "outsider-sftp"
  acl    = "private"
}

사용자 인증 정보를 관리할 Secret Manager를 만들자. Secret Manager는 한 시크릿당 한 달에 $0.40이고 API 호출 만 건당 $0.05다. 여기서는 한 시크릿으로 JSON으로 사용자 정보를 관리할 것이므로 비용은 크게 신경 쓰지 않아도 된다.

resource "aws_secretsmanager_secret" "sftp_users" {
  name        = "sftp-users"
  description = "SFTP users"
}

당연히 AWS에 잘 생성되었고 특별히 다른 설정은 하지 않았다.

시크릿 매니저에 생성된 시크릿

API Gateway

ID 제공자와 협력 문서를 참고하면 Transfer 서버가 API Gateway에 요청할 때 /servers/:serverId/users/:username/config로 요청을 보내는데 여기서 :serverId는 Transfer 서버의 아이디가 들어가고 :username는 SFTP 사용자의 유저네임이 들어간다.

그러므로 API 게이트웨이가 이 요청을 받도록 설정해야 한다. API Gateway가 꽤 복잡하기 때문에 Terraform 코드도 상당히 복잡해진다. API Gateway를 별로 좋아하진 않는데 Transfer 서버가 API Gateway로만 커스텀 아이덴티티 프로바이더를 지원하기 때문에 어쩔 수가 없다.

resource "aws_api_gateway_rest_api" "sftp_auth" {
  name        = "SFTP-Auth"
  description = "SFTP User authentication with Secret Manager and Lambda"

  endpoint_configuration {
    types = ["REGIONAL"]
  }
}

위 설정은 API Gateway의 리소스 설정이다. 보통 웹 프레임워크에서는 /servers/:serverId/users/:username/config 같은 경로를 한 번에 설정할 수 있지만, Terraform으로 API Gateway를 다룰 때는 경로 단계마다 리소스를 생성해서 parent_id로 연결해 줘야 한다.

// /servers
resource "aws_api_gateway_resource" "sftp_auth_servers" {
  rest_api_id = aws_api_gateway_rest_api.sftp_auth.id
  parent_id   = aws_api_gateway_rest_api.sftp_auth.root_resource_id
  path_part   = "servers"
}

// /servers/{serverId}
resource "aws_api_gateway_resource" "sftp_auth_serverid" {
  rest_api_id = aws_api_gateway_rest_api.sftp_auth.id
  parent_id   = aws_api_gateway_resource.sftp_auth_servers.id
  path_part   = "{serverId}"
}

// /servers/{serverId}/users
resource "aws_api_gateway_resource" "sftp_auth_users" {
  rest_api_id = aws_api_gateway_rest_api.sftp_auth.id
  parent_id   = aws_api_gateway_resource.sftp_auth_serverid.id
  path_part   = "users"
}

// /servers/{serverId}/users/{username}
resource "aws_api_gateway_resource" "sftp_auth_username" {
  rest_api_id = aws_api_gateway_rest_api.sftp_auth.id
  parent_id   = aws_api_gateway_resource.sftp_auth_users.id
  path_part   = "{username}"
}

// /servers/{serverId}/users/{username}/config
resource "aws_api_gateway_resource" "sftp_auth_config" {
  rest_api_id = aws_api_gateway_rest_api.sftp_auth.id
  parent_id   = aws_api_gateway_resource.sftp_auth_username.id
  path_part   = "config"
}

/servers/:serverId/users/:username/config를 GET 요청으로 받아야 하므로 위에서 만든 aws_api_gateway_resource에 GET 메서드를 추가하고 이 요청을 Transfer 서버에서 요청해야 하므로 authorizationAWS_IAM으로 지정했다. 이 IAM Role은 뒤에서 생성할 것이다.

resource "aws_api_gateway_method" "sftp_auth_get" {
  rest_api_id   = aws_api_gateway_rest_api.sftp_auth.id
  resource_id   = aws_api_gateway_resource.sftp_auth_config.id
  http_method   = "GET"
  authorization = "AWS_IAM"
}

이 요청은 결국 Lambda를 호출하게 되는데 Lambda가 호출한 뒤에 돌려줄 응답을 먼저 정의하자. 복잡한 오류 상황을 모두 정의할 필요는 없음으로 여기서는 200 OK에 대한 응답만 정의했다.

resource "aws_api_gateway_method_response" "sftp_auth_get_200" {
  rest_api_id = aws_api_gateway_rest_api.sftp_auth.id
  resource_id = aws_api_gateway_resource.sftp_auth_config.id
  http_method = aws_api_gateway_method.sftp_auth_get.http_method
  status_code = "200"

  response_models = {
    "application/json" = aws_api_gateway_model.sftp_auth_user.name
  }
}

resource "aws_api_gateway_model" "sftp_auth_user" {
  rest_api_id  = aws_api_gateway_rest_api.sftp_auth.id
  name         = "userinfo"
  description  = "API response for sftp user"
  content_type = "application/json"

  schema = <<EOF
  {
    "$schema": "http://json-schema.org/draft-04/schema#",
    "title" : "user authencation result data structure",
    "type": "object",
    "properties": {
      "Role": { "type": "string" },
      "Policy": { "type": "string" },
      "HomeDirectory": { "type": "string" },
      "HomeBucket": { "type": "string" }
    }
  }
  EOF
}

연결된 aws_api_gateway_model에서 정의한 모델대로 이 응답은 다음과 같은 JSON을 돌려준다. 이 정보가 Transfer 서버가 사용할 정보인데 사용자에게 할당할 IAM Role과 여기에 추가로 부여할 정책, Transfer 서버가 사용한 S3 버킷, 홈 디렉터리를 지정해서 돌려줄 수 있다. 다시 말하면 Lambda의 코드에 따라 응답을 동적으로 만들어서 돌려줄 수 있고 그에 따라 Transfer 서버의 사용자에게 다양한 설정과 권한을 줄 수 있다.

{
  "Role": "",
  "Policy": "",
  "HomeDirectory": "",
  "HomeBucket": ""
}


Lambda

API Gateway의 요청을 받아서 Secret Manager에서 정보를 조회에서 사용자 로그인 정보를 확인하는 Lambda 코드를 lambda/index.js파일로 작성해 보자.

// lambda/index.js
const AWS = require('aws-sdk');

const client = new AWS.SecretsManager({
  region: process.env.REGION,
});

exports.handler = (event, context, callback) => {
  console.log('Event:', JSON.stringify(event));
  const username = event.pathParameters.username;
  const serverId = event.pathParameters.serverId;
  const password = event.headers.Password;

  const response = {
    statusCode: 200,
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Content-Type": "application/json"
    },
    body: JSON.stringify({}),
    isBase64Encoded: false
  };

  if (!username || !serverId || !password) {
    console.log('Error: required fields are missing');
    return callback(null, response);
  }

  client.getSecretValue({ SecretId: process.env.SECRET_ID }, (err, data) => {
    if (err) {
      console.log('Error: cannot retriving secrets')
      return callback(null, response);
    } else {
      const secrets = JSON.parse(data.SecretString);

      for (const user in secrets) {
        secrets[user] = JSON.parse(secrets[user]);
      }

      const user = secrets[username];
      if (user && user.password === password) {
        const policy = {
          Version: '2012-10-17',
          Statement: [
            {
              Sid: 'AllowListingOfUserFolder',
              Effect: 'Allow',
              Action: ['s3:ListBucket'],
              Resource: [`arn:aws:s3:::${process.env.BUCKET_NAME}`],
              Condition: {
                StringLike: { 's3:prefix': [
                  '${transfer:UserName}/*',
                  '${transfer:UserName}'
                ] }
              }
            },
            {
              Sid: 'AWSTransferRequirements',
              Effect: 'Allow',
              Action: ['s3:ListAllMyBuckets', 's3:GetBucketLocation'],
              Resource: '*'
            },
            {
              Sid: 'HomeDirObjectAccess',
              Effect: 'Allow',
              Action: [
                's3:PutObject',
                's3:GetObject',
                's3:DeleteObject',
                's3:DeleteObjectVersion',
                's3:GetObjectVersion',
                's3:GetObjectACL',
                's3:PutObjectACL'
              ],
              Resource: ['arn:aws:s3:::${transfer:HomeDirectory}/*']
            }
          ]
        };

        const responseBody = {
          Role: process.env.S3_ROLE,
          HomeDirectory: `/${process.env.BUCKET_NAME}/${username}`,
        };

        if (user.type !== 'admin') {
          responseBody.Policy = JSON.stringify(policy);
        } else {
          responseBody.HomeDirectory = `/${process.env.BUCKET_NAME}/`;
        }

        response.body = JSON.stringify(responseBody);
        console.log('Success:', JSON.stringify(response));
        return callback(null, response);
      } else {
        console.log('Error: username and password not matched');
        return callback(null, response);
      }
    }
  });
};

100라인 정도 되는 Lambda 코드인데 알아야 할 부분을 정리해 두었다. 그리고 사용자에 type을 지정해서 어드민과 일반 사용자를 구분했다. 1편에서 SFTP로 로그인한 사용자가 다른 사용자의 디렉터리에도 접근할 수 있었던 sftp_user IAM Role(aws_iam_role.sftp_user)을 여기서도 그대로 사용할 예정인데 이 Role은 어드민으로 사용하고 추가 정책을 부여해서 일반 사용자는 다른 사용자의 디렉터리에 접근하지 못하도록 할 예정이다.

  • aws-sdk는 Lambda에 이미 설치되어 있음으로 따로 설치해서 패키징하진 않아도 된다.
  • 환경 변수로 지정한 REGION, SECRET_ID, BUCKET_NAME, S3_ROLE은 Lambda를 생성할 때 설정해 줄 것이다.
  • 10번 라인의 event.pathParameters.serverId, event.pathParameters.username는 위에서 /servers/:serverId/users/:username/config의 경로로 넘어온 serverId, username의 값인데 handlerevent 파라미터로 자동으로 넘어온다.
  • 12번 라인의 SFTP 사용자의 비밀번호는 헤더로 넘어오므로 event.headers.Password로 접근할 수 있다.
  • 29번 라인의 client.getSecretValue({ SecretId: process.env.SECRET_ID }, callback)으로 위에서 만든 시크릿 매니저에 저장된 값을 가져온다. 사용자마다 따로 가져올 수 있는 건 아니고 전체 JSON을 다 가져와서 비교해야 한다.
  • 42번 라인의 policy는 로그인한 사용자(유저네임과 비밀번호가 일치했을 때) 사용자에게 부여할 S3 정책이다. 위에서 설명한 대로 일반 사용자에게는 자신의 홈 디렉터리만 보이도록 IAM 정책을 추가하고 이를 응답에 돌려준다.

Lambda에 올리려면 zip 파일로 올려야 하므로 lambda 디렉터리에 들어가서 zip으로 압축을 한다.

$ zip -r ./sftp-auth.zip .
  adding: index.js (deflated 65%)
resource "aws_lambda_function" "sftp_auth" {
  function_name = "sftp-auth"
  description   = "SFTP User authentication with Secret Manager"
  role          = aws_iam_role.sftp_auth_for_lambda.arn

  runtime = "nodejs12.x"
  timeout = "30"

  filename         = "./lambda/sftp-auth.zip"
  source_code_hash = filebase64sha256("lambda/sftp-auth.zip")
  handler          = "index.handler"

  environment {
    variables = {
      SECRET_ID   = aws_secretsmanager_secret.sftp_users.name,
      REGION      = "ap-northeast-2",
      S3_ROLE     = aws_iam_role.sftp_user.arn,
      BUCKET_NAME = aws_s3_bucket.stfp_sample.id,
    }
  }
}

위에서 압축한 lambda/sftp-auth.zip를 여기서 지정해서 Lambda를 생성한다. 사실 Terraform으로 Lambda를 관리하는 건 별로 편하진 않은데(API Gateway도 마찬가지) 자주 바꾸진 않을 것 같기도 하고 리소스를 Terraform으로 설정하던 중이라 Serverless를 섞어 쓰고 싶지 않아서 Lambda까지 Terraform으로 설정했다.

이제 API Gateway에서 이 Lambda를 호출하고 Lambda가 Secret Manage에 접근할 수 있도록 aws_iam_role.sftp_auth_for_lambda를 생성하고 권한을 부여한다.

resource "aws_lambda_permission" "sftp_auth" {
  depends_on = [
    aws_api_gateway_method.sftp_auth_get,
    aws_api_gateway_method_response.sftp_auth_get_200
  ]
  statement_id  = format("AllowExecution-%s-%s", aws_lambda_function.sftp_auth.function_name, "ap-northeast-2")
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.sftp_auth.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_api_gateway_rest_api.sftp_auth.execution_arn}/*/*/*"
}

resource "aws_iam_role" "sftp_auth_for_lambda" {
  name               = "sftp-auth-for-lambda"
  description        = "authentication lambda for SFTP"
  assume_role_policy = data.aws_iam_policy_document.sftp_auth_for_lambda_assume.json
}

data "aws_iam_policy_document" "sftp_auth_for_lambda_assume" {
  version = "2012-10-17"
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

resource "aws_iam_policy" "sftp_auth_for_lambda" {
  name        = "sftp-auth-lambda-executor"
  path        = "/"
  description = "IAM policy to run lambda"

  policy = data.aws_iam_policy_document.sftp_auth_for_lambda.json
}

data "aws_iam_policy_document" "sftp_auth_for_lambda" {
  version = "2012-10-17"
  statement {
    effect = "Allow"
    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]
    resources = ["arn:aws:logs:*:*:*"]
  }
  statement {
    effect = "Allow"
    actions = [
      "secretsmanager:Get*",
      "secretsmanager:DescribeSecret",
      "secretsmanager:ListSecretVersionIds"
    ]
    resources = [aws_secretsmanager_secret.sftp_users.arn]
  }
}

resource "aws_iam_role_policy_attachment" "sftp_auth_for_lambda" {
  role       = aws_iam_role.sftp_auth_for_lambda.name
  policy_arn = aws_iam_policy.sftp_auth_for_lambda.arn
}


Transfer 서버, API Gateway, Lambda, Secret Manager 연동

API 게이트웨이와 lambda를 연동하자. aws_api_gateway_integration으로 연동을 해야 API 게이트웨이로 들어온 GET 요청이 Lambda를 호출할 수 있다.

resource "aws_api_gateway_integration" "sftp_auth_get" {
  depends_on              = [aws_api_gateway_method.sftp_auth_get]
  rest_api_id             = aws_api_gateway_rest_api.sftp_auth.id
  resource_id             = aws_api_gateway_resource.sftp_auth_config.id
  http_method             = aws_api_gateway_method.sftp_auth_get.http_method
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = "arn:aws:apigateway:ap-northeast-2:lambda:path/2015-03-31/functions/${aws_lambda_function.sftp_auth.arn}/invocations"
}

API Gateway 설정이 완료되었으므로 이를 배포한다. API 게이트웨이는 배포해야 실제로 사용할 수 있다.

resource "aws_api_gateway_deployment" "sftp_auth_prod" {
  depends_on        = [aws_api_gateway_integration.sftp_auth_get]
  rest_api_id       = aws_api_gateway_rest_api.sftp_auth.id
  stage_name        = "prod"
}

너무 많은 설정을 했는데 드디어 Transfer 서버를 만들 차례다. 1편에서 다룬 것과 달리 identity_provider_typeAPI_GATEWAY로 지정했고 커스텀 호스트네임을 사용하기 위해 null_resource를 사용했다.(1편 참고)

resource "aws_transfer_server" "custom_sftp" {
  endpoint_type          = "PUBLIC"
  identity_provider_type = "API_GATEWAY"
  invocation_role        = aws_iam_role.sftp_auth_api_invocation.arn
  url                    = aws_api_gateway_deployment.sftp_auth_prod.invoke_url
}

resource "null_resource" "custom_sftp_custom_hostname" {
  depends_on = [aws_transfer_server.custom_sftp]

  provisioner "local-exec" {
    command = <<EOF
aws transfer tag-resource \
  --arn '${aws_transfer_server.custom_sftp.arn}' \
  --tags 'Key=aws:transfer:customHostname,Value=sftp.outsider.dev' \
  --region 'ap-northeast-2'
EOF
  }
}

Transfer 서버에서 API Gateway를 호출할 수 있도록 aws_iam_role.sftp_auth_api_invocation을 생성한다. 위에서 API Gateway의 인증을 AWS_IAM으로 설정했고 여기서 Role을 지정한 것이다.

resource "aws_iam_role" "sftp_auth_api_invocation" {
  name               = "sftp-auth-api-invocation"
  description        = "invoke API gateway from tranfer SFTP"
  assume_role_policy = data.aws_iam_policy_document.sftp_auth_api_invocation_assume.json
}

data "aws_iam_policy_document" "sftp_auth_api_invocation_assume" {
  version = "2012-10-17"
  statement {
    effect = "Allow"

    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["transfer.amazonaws.com"]
    }
  }
}

resource "aws_iam_role_policy_attachment" "sftp_auth_api_invocation" {
  role       = aws_iam_role.sftp_auth_api_invocation.name
  policy_arn = aws_iam_policy.sftp_auth_api_invocation.arn
}

resource "aws_iam_policy" "sftp_auth_api_invocation" {
  name   = "sftp-auth-api-invocation"
  policy = data.aws_iam_policy_document.sftp_auth_api_invocation.json
}

data "aws_iam_policy_document" "sftp_auth_api_invocation" {
  version = "2012-10-17"
  statement {
    effect  = "Allow"
    actions = ["execute-api:Invoke"]
    resources = [
      "${aws_api_gateway_rest_api.sftp_auth.execution_arn}/*/*/*"
    ]
  }
  statement {
    effect    = "Allow"
    actions   = ["apigateway:GET"]
    resources = ["*"]
  }
}

시크릿 매니저에서 사용자를 추가해보자. 키/밸류 형식으로 값을 설정해서 추가할 수 있는데 추가 필드를 설정하기 위해서 밸류에도 JSON으로 값을 지정했다.

시크릿 매니저에 시크릿 값으로 설정한 사용자 정보

전체를 JSON으로 지정하면 아래처럼 표현이 된다. 이 전체 값을 가져와서 위에서 작성한 Lambda가 필요한 필드를 가져다가 값을 비교할 수 있다.

{
  "user1": "{ \"password\": \"test1\" }",
  "admin1": "{ \"password\": \"admin1\", \"type\":\"admin\" }"
}

FTP 클라이언트로 user1로 로그인을 하면 패스워드를 이용해서 잘 로그인되고 파일 업로드가 가능한 걸 볼 수 있다. Lambda에서 정책으로 사용자 홈 디렉터리만 사용 가능하도록 했기 때문에 상위 디렉터리로 이동해도 다른 디렉터리는 보이지 않는다.

user1로 로그인한 SFTP

사용자 비밀번호를 잘못 입력하면 로그인이 되지 않는다.

SFTP 사용자 비밀번호가 틀리면 로그인 오류가 발생

admin1로 로그인하면 루트가 홈디렉터리로 설정되고 모든 폴더에 접근 가능한 것을 알 수 있다.

admin1로 로그인하면 루트로 접속되어 전체 폴더를 확인할 수 있다

이 모든 과정을 보면 그냥 FTP 서버를 띄우면 되지 이렇게 복잡한 설정으로 할 필요가 있나 싶은 생각이 들 수도 있다. 설명하느라고 더 복잡해지긴 했지만, Terraform으로 한번 설정하면 AWS가 다 관리해 주므로 별도로 운영할 필요는 없고(Transfer 서버가 비싸긴 하지만...) 사용자도 Secret Manager에서 쉽게 추가/삭제할 수 있다.

2020/10/02 19:27 2020/10/02 19:27