배경
외부로 제공되는 API의 인증을 위해 기존에는 AWS Cognito와 API Gateway를 사용해왔습니다.
AWS Cognito는 유저 관리와 인증에 필요한 다양한 기능들을 제공하는데요, 저희가 필요한 기능은 그 중 일부에 불과해서 전체 기능을 충분히 활용하고 못하는 아쉬움이 있었습니다.
그래서 우리에게 더 알맞은 인증 구조를 고민하게 되었고, 그 결과 Lambda를 활용해 Custom Authorizer를 구현하기로 했습니다. 최종적으로 완성된 구조를 다이어그램으로 나타내면 다음과 같습니다.
API Gateway가 클라이언트의 요청을 받으면 페이로드(token or request)를 찾아서 권한 부여자(Authorizer, auth function)를 실행하고, auth function은 검증 절차를 걸친 다음 AuthPolicy(Allow / Deny)를 반환합니다. 만약, Deny Policy를 받는다면 API Gateway는 클라이언트에게 403 응답을 반환하고, Allow Policy를 받으면 API Gateway의 HTTP 이벤트를 받을 타겟(Lambda 혹은 EC2)을 호출하는 방식으로 흘러갑니다.
더 자세한 워크플로우는 다음을 참고해주세요.
API Gateway Lambda 권한 부여자 사용 - Amazon API Gateway
REQUEST 권한 부여자를 사용하여 API에 대한 액세스를 제어하는 것이 좋습니다. TOKEN 권한 부여자를 사용할 때는 단일 자격 증명 소스를 기반으로 하지만 REQUEST 권한 부여자를 사용할 때는 여러 자
docs.aws.amazon.com
참고로 Lambda Authorizer는 Request Parameter 방식과 Token 방식이 존재하는데요, Request 방식은 요청 정보의 더 많은(HTTP header, query parmeter, stageVariables, $context 변수 등)를 다양하게 받을 수 있고 Token 방식은 하나의 헤더에서 토큰을 받는 방식입니다. 따라서 필요에 따라 어떠한 방식으로 선택할 지 결정하면 됩니다.
https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html#api-gateway-lambda-authorizer-choose
활용
이번에 API 인증 방식을 Lambda로 변경하면서 인프라분의 조언과 도움을 받아 Serverless Framework@v3를 활용하게 되었는데요,
주로 AWS Lambda와 같은 서버리스 서비스를 쉽게 배포할 수 있는 기능을 지원하고, CLI나 대시보드도 제공해서 무척이나 유용했습니다.
전체 프로젝트 코드는 다음을 참고해주세요
https://github.com/ahn-sj/kotlin-lambda-demo
GitHub - ahn-sj/kotlin-lambda-demo: [practice] kotlin lambda demo project
[practice] kotlin lambda demo project. Contribute to ahn-sj/kotlin-lambda-demo development by creating an account on GitHub.
github.com
간단한 예시로 미리 지정한 헤더 키(여기서는 X-Service-Key)의 값을 찾아서 지정한 값과 일치하는지 확인해서 인증 여부에 따라 API Gateway Lambda Form에 맞추어 Lambda authorizer function's output(principalId & policy)을 내려주면 됩니다.
즉, X-Service-Key 헤더에 값이 'tally'와 일치한다면 허용(✅)하고, 나머지는 거부(❌)하는 Authorizer입니다.
class Handler : RequestHandler<TokenAuthorizerContext, Map<String, Any>> {
private val validServiceKey = "tally"
override fun handleRequest(
input: TokenAuthorizerContext,
context: Context
): Map<String, Any> {
val effect = when (input.authorizationToken) {
validServiceKey -> "Allow"
else -> "Deny"
}
return generatePolicy(effect, input.methodArn.orEmpty(), principalId = "user")
}
private fun generatePolicy(
effect: String,
resource: String, principalId: String
) = mapOf(
"principalId" to principalId,
"policyDocument" to mapOf(
"Version" to "2012-10-17",
"Statement" to listOf(
mapOf(
"Action" to "execute-api:Invoke",
"Effect" to effect,
"Resource" to resource
)
)
)
)
data class TokenAuthorizerContext(
val type: String? = null,
val authorizationToken: String? = null,
val methodArn: String? = null
)
}
빌드 도구로 프로젝트를 빌드해서 Lambda에 배포(serverless deploy)하고 배포된 Lambda를 권한 부여자로 지정하면 API Gateway에 작성한 Lambda가 권한 부여자로 등록되게 됩니다. 연결할 리소스에 권한 부여자를 적용하게 되면 클라이언트의 요청이 오면 X-Service-Key 헤더의 토큰을 인식해서 그 토큰을 API Gateway Authorizer(auth-demo-handler-dev-hello)가 인증 검사를 하는 절차가 되게 됩니다.
자체적으로 제공해주는 시뮬레이션 테스트를 통해 정상적인 응답이 오는지를 확인해보면 올바른 키(tally)과 그렇지 않은 키(zxczxc)에 따라 Effect(Allow | Deny)의 상태가 변하는 것을 확인할 수 있습니다.
실제 API 리소스를 생성하여 권한 부여자를 붙인 다음 테스트를 해보면 다음의 결과를 받게 됩니다
1. 헤더 자체가 없는 경우
{
"message": "Unauthorized"
}
2. 헤더는 있지만 실패한 경우
{
"message": "User is not authorized to access this resource with an explicit deny"
}
여기에 한 단계 더 나아가 API Gateway에서 제공하는 Resource Policy로 API 리소스들을 정책에 맞게 화이트/블랙리스트 방식으로 관리할 수 있고, Throttling을 통해 트래픽 양도 제어할 수 있게 되면서 더 높은 수준의 보안과 안정성을 유지할 수 있게 됩니다.
Resource Policy까지 적용되게 되면 다음의 워크플로우로 동작되게 됩니다.
결론
포스팅에서는 단순 헤더의 키를 확인하도록 구성하였는데요, Lambda는 벤더 종속적이지 않고 자유도도 높기 때문에 OAuth, JWT, Custom Key 등으로도 활용 가능할 수 있습니다.
아무래도 보안적인 요소도 포함되어 있기 때문에 더 나은 방법이 있거나 우려되는 사항이 있다면 언제든 알려주세요 !
글 작성하는데 걸린 시간: 약 4시간 20분
참고
- https://velog.io/@xgro/Lambda-Authorizer
- https://lacti.github.io/2019/08/01/api-gateway-custom-authorizer/