AWS IoT Core와 연결을 진행할 때 인증서가 아닌 사용자 지정 권한부여자를 적용하는 방법과 실제 적용당시 발생했던 문제를 소개하며 그 해결 방법도 안내하겠다.
AWS IoT Core Custom Authorier 적용
목차1. AWS IoT Core Custom Authorizer란?
1. AWS IoT Core Custom Authorizer란?
AWS IoT Core Custom Authorizer 한국어로는 사용자 지정 권한 부여자라고 불리는 AWS IoT 기능은 자체 클라이언트 인증 및 권한 부여를 관리할 수 있다. 이는 기본적으로 지원하는 AWS IoT Core 인증 메커니즘 이외의 인증 메커니즘을 사용해야 할 때 유용하다.
예를 들어 현장의 기존 장치를 AWS IoT Core 마이그레이션 하고 이러한 장치가 사용자 지정 베어러 토큰 또는 MQTT 사용자 이름 및 암호를 사용하여 인증하는 경우 새 ID를 제공하지 AWS IoT Core 않고도 장치를 마이그레이션 할 수 있다. 지원되는 모든 통신 프로토콜과 함께 사용자 지정 인증을 사용할 수 있다.
이해하기 쉽게 말하자면 AWS IoT core에 연결하려는 기기에서 인증을 위해 X.509 인증서를 사용해서 인증하는 방법이 아닌 기존 사용자의 API 서버에서 발행한 JWT 토큰을 이용해서 AWS IoT Core에 접근할 수 있다는 것이다.
2. Custom Authorizer를 도입하게된 배경
IoT Core에 대한 내용은 AWS IoT Core 시리즈를 확인하길 바란다.
내가 IoT 기기와 연동하기 위한 서버를 구현할 때 동시에 모바일 앱 개발을 진행하고 있었고 모바일 앱에 맞추어 API도 구현을 진행하고 있었다. 이때 모바일 앱 기획에 모바일 앱에서 충전 Dock이 클라우드로 전송하는 센서 데이터를 거의 실시간으로 앱에서도 확인을 할 수 있어야 한다는 요구가 있었다. 이전 구현 내용에 보면 데이터는 DynamoDB로 저장이 되고 있다. 이런 상황에서 만약 모바일 앱에서 DynamoDB에 5초 간격으로 저장되는 데이터를 API호출을 통해 전달받는 다면 이 과정에서 데이터 조회를 위해 많은 양의 작업이 동반이 될 것이라는 이슈가 발생했다. 물론 서버 API를 이용해 이를 처리할 수 있을 수 있다. 또한 다른 방법을 이용해서 이러한 이슈를 해결할 방법이 충분히 있을 수 있다고 생각한다. 하지만 첫 회사에서 사수 없이 혼자 고군분투하던 개발자가 이런 경험이 어디 있었겠는가 나는 최대한 AWS IoT Core를 활용하는 방안을 생각했다.
내가 집중했던 부분은 실시간으로 데이터를 표시할 수 있어야 한다는 점이었다. 실시간으로 데이터를 표시하겠다는 것은 모바일 앱이 직접적으로 AWS의 서비스에서 데이터를 조회하는 것이었다. 즉 모바일 앱이 AWS에 인증을 해서 서비스에 접근할 권한을 얻는 것이다. 이를 위해 여러 방안을 찾아 보았다. AWS Cognito의 자격증명 풀이 있을 수 있다. 나는 첫 번째 방법으로 STS를 이용한 임시 자격 증명을 모바일 앱에 제공하는 것이었다. STS에 대한 내용은 추후에 다룰 수 있으면 좋겠다.
모바일 앱이 로그인을 진행할 때 서버에서 STS를 이용해 발급 받은 임시 자격을 access_token과 함께 모바일 앱에 전달을 하는 것이었다. 이에 대한 테스트를 진행했고 사전에 구현했던 모의 기기와 Python을 이용해 만든 가상의 모바일 앱에서 STS 임시 자격 증명을 이용해 AWS IoT SDK를 이용해 특정 토픽에 연결하여 IoT 기기에서 보내는 센서 데이터를 중간에 수신받을 수 있었다.
이때 AWS와 AWS IoT Core 도입과 관련해서 미팅을 주관했었고 나는 지금까지의 구현 상황을 정리해서 AWS에 문의했다. 그리고 AWS에서는 IoT Speciallist와 함께 미팅에 참여했고 이때 STS를 사용하는 것은 AWS IoT Core의 Custom Authorizer를 이용해서 대체할 수 있을 것 같다는 답변을 받았다. 이 외에도 구현에 있어 여러 조언을 얻을 수 있어서 매우 유익한 미팅이었다.
실습에 앞서 먼저 AWS STS와 AWS IoT Core Custom Authorizer의 차이점을 간단히 알고 넘어가겠다.
- AWS STS
- 목적 : 일시적인 자격 증명을 발급하는 서비스로 사용자가 특정한 권한을 가진 임시 자격 증명을 사용하여 AWS 리소스에 접근할 수 있게 해준다.
- 즉 STS를 통해 AWS의 리소스에 제한된 시간 동안 접근할 수 있도록 하는 것이다.
- AWS IoT Core Custom Authorizer
- 목적 : AWS IoT Core에서 MQTT 클라이언트의 인증 및 권한 부여를 커스터마이징 할 수 있는 기능
- 즉 AWS IoT Core에 연결할 때 X.509 인증서를 사용하는 방법 외에 사용자 정의 인증 로직을 이용해 토큰 기반의 인증 로직을 이용해 AWS IoT Core에 연결할 수 있도록 하는 것이다.
물론 STS를 사용하여 AWS IoT에 사용할 수 있지만 목적이 다르다는 것이다. 만약 AWS IoT Core에 연결된 기기가 S3등의 다른 AWS 서비스에 접근을 해야 한다면 STS를 사용할 수 있으며 IoT 기기가 인증서 기반의 연결이 아닌 다른 방식의 인증을 통해 AWS IoT Core에 연결되기 위해서 Custom Authorizer를 사용한다고 생각하면 된다.
3. Custom Authorizer 적용 실습
먼저 실습을 위해서는 모의 기기가 있어야 한다. 만약 모의 기기를 구현하지 않았다면 앞선 글들을 통해 모의 기기 구현을 진행하는 것을 추천한다.
설명을 위해 AWS 콘솔에서 작업을 진행하겠다. 먼저 AWS IoT Core로 검색을 통해 이동한다. 그리고 보안 - 권한 부여자로 이동한다.
권한 부여자 생성을 선택하여 이동해야 한다.
권한 부여자 이름을 demo-custom-authorizer로 설정했다. 물론 본인이 원하는 이름을 사용하면 된다.
권한 부여자 상태를 활성으로 선택을 해야 한다. 이 설정을 활성으로 해야 권한부여자가 설정이 된다.
아래로 스크롤을 내리면 권한 부여자 함수를 선택하는 칸이 있다. 먼저 설명부터 하고 람다 함수를 만들러 이동하겠다. HTTP 권한 부여 캐싱의 경우 클라이언트가 권한 부여자를 이용해 인증을 할 때 매번 연결될 때마다 인증을 검사할 건지 아니면 캐싱을 해 둘 것인지를 선택하는 것이다. 나는 활성으로 설정을 하겠다.
선택 사항으로 토큰 검증이라는 설정이 있는데 토큰 검증의 경우 토큰을 이용한 검증을 설정하는 것인데 토큰 키 이름의 경우 클라이언트가 연결 시에 토큰을 전달할 때 사용해야 하는 HTTP 헤더 또는 쿼리 파리미터의 키 이름을 설정하는 것이며 "token_key_name"이 지정되었다면, 클라이언트는 연결 요청 시? token_key_name=<토큰 값>과 같이 토큰을 전달해야 한다.
퍼블릭 키는 클릭하면 아래와 같이 표시되는데 서명된 토큰일 경우 퍼블릭 키를 사용하여 토큰의 서명을 검증할 수 있도록 하기 위해 사용된다고 한다. 최대 2개의 퍼블릭 키를 사용할 수 있으며, 이를 통해 신뢰할 수 있는 소스에서 발급되었는지를 확인하는 기능이라고 한다.
뒤에 설명하겠지만 이 설정을 제대로 이해하지 않고 무심코 설정했다가 모든 문제의 원인이 되어 2일간 끊임없는 트러블 슈팅을 경험해야 했다. 선택 사항은 선택사항인 이유가 있다.
그럼 계속 진행해겠다. 위에 권한 부여자 함수 부분에 Lambda를 선택하지 않았는데 지금부터 생성을 진행하겠다. Lambda 함수 생성을 마우스 우클릭 후 새 탭에서 링크 열기를 실행한다.
Lambda를 생성한다. 함수 이름은 실습에서는 demo-custom-authorizer-function이라고 했지만 원하는 이름을 사용하면 된다. 런타임의 경우 Python 3.12로 설정했다. 다른 설정은 하지 않고 생성을 진행하겠다.
!! 지금 보니 함수 이름에 오타가 나 debo-custom-authorizer-function이 되었지만 넘어가겠다.
Lambda가 생성되면 코드를 작성하겠다.
import json
import re
import logging
import os
import jwt
import base64
from jwt.exceptions import InvalidTokenError
log = logging.getLogger("IoTCoreCustomAuthorizer")
log.setLevel(logging.DEBUG)
SECRET_KEY = os.environ.get("SECRET_KEY")
def buildPolicyDocument(client_id):
return {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"iot:Receive",
"iot:Publish"
],
"Resource": [
"arn:aws:iot:<region>:<account_id>:topic/<topic>"
]
},
{
"Effect": "Allow",
"Action": [
"iot:Subscribe"
],
"Resource": [
"arn:aws:iot:<region>:<account_id>:topicfilter/<topic>"
]
},
{
"Effect": "Allow",
"Action": [
"iot:Connect"
],
"Resource": f"arn:aws:iot:<region>:<account_id>:client/{client_id}"
}
]
}
def verify_jwt_token(token, secret_key):
try:
# JWT 토큰의 형식을 수동으로 확인
if token.count('.') != 2:
raise InvalidTokenError("Invalid JWT token structure")
# HS256 알고리즘으로 서명된 JWT 토큰 검증
decoded_token = jwt.decode(token, secret_key, algorithms=["HS256"])
return decoded_token
except InvalidTokenError as e:
log.error(f"JWT 검증 실패: {str(e)}")
return None
def sanitize_principal_id(principal_id):
sanitized_id = re.sub(r'[^a-zA-Z0-9]', '', principal_id)
return sanitized_id
def lambda_handler(event, context):
try:
print(json.dumps(event))
password = event['protocolData']['mqtt']['password']
print("\n Base64 encoded Password is: " + password)
first_decoded = base64.b64decode(password).decode('utf-8')
print("\n First Base64 decoded Password is: " + first_decoded)
decoded_password = base64.b64decode(first_decoded).decode('utf-8')
print("\n Second Base64 decoded Password is: " + decoded_password)
# JWT 토큰 검증
decoded_token = verify_jwt_token(decoded_password, SECRET_KEY)
if not decoded_token:
raise Exception("Invalid token.")
client_id = decoded_token.get("sub")
log.info("*** Client ID: %s", client_id)
if not client_id:
raise Exception("Token does not contain 'sub' claim.")
log.info("Password Authentication: Success")
sanitized_client_id = sanitize_principal_id(client_id)
returnValue = {
"isAuthenticated": True,
"principalId": sanitized_client_id,
"disconnectAfterInSeconds": 86400,
"refreshAfterInSeconds": 300,
"policyDocuments": [buildPolicyDocument(event['protocolData']['mqtt']['clientId'])]
}
log.info("*** RETURN: %s", json.dumps(returnValue, indent=4))
except Exception as e:
log.error(str(e))
returnValue = {
"isAuthenticated": False,
"principalId": None,
"disconnectAfterInSeconds": 0,
"refreshAfterInSeconds": 0,
"policyDocuments": []
}
return returnValue
위 코드를 설명하기 전에 파이썬에서 기본적으로 제공하는 라이브러리가 아닌 PyJWT 라이브러리를 Lambda에 계층으로 주입하겠다.
mkdir -p lambda_layer/python
pip install PyJWT -t lambda_layer/python
cd lambda_layer
zip -r9 ../lambda_layer.zip .
람다 계층 페이지에서 계층 생성을 클릭한다.
PyJWT라고 계층의 이름을 설정하고 위에서 압축한 lambda_layer.zip 파일을 업로드하고 호환 런타임은 Python 3.12로 했다. 생성을 누르고 람다 함수로 돌아가 코드 입력창 아래로 스크롤하여 계층 부분에 [Add a layer]를 클릭한다.
위에서 생성한 계층을 선택하여 추가하면 된다.
Lambda 함수에 환경변수로 SECRET_KEY를 key 값으로 하고 value를 실제 JWT 토큰을 생성할 때 사용하는 값으로 입력하고 저장을 한다.
이제 지금까지 탭에 고이 모셔두었던 권한 부여자 생성 페이지를 다시 열어본다.
새로 생성한 Lambda 함수를 선택하고 권한부여자를 생성한다.
권한 부여자가 활성화 상태가 되어 있다면 문제가 없다.
{
"token" :"aToken",
"signatureVerified": Boolean, // Indicates whether the device gateway has validated the signature.
"protocols": ["tls", "http", "mqtt"], // Indicates which protocols to expect for the request.
"protocolData": {
"tls" : {
"serverName": "serverName" // The server name indication (SNI) host_name string.
},
"http": {
"headers": {
"#{name}": "#{value}"
},
"queryString": "?#{name}=#{value}"
},
"mqtt": {
"username": "myUserName",
"password": "myPassword", // A base64-encoded string.
"clientId": "myClientId" // Included in the event only when the device sends the value.
}
},
"connectionMetadata": {
"id": UUID // The connection ID. You can use this for logging.
},
}
위 값은 AWS IoT Core의 개발자 가이드의 내용이다. 클라이언트에서 AWS IoT Core의 custom Authorizer를 호출하면 Lambda 함수의 이벤트로 다음과 같은 형식의 데이터가 전달이 된다. 내가 추가한 코드를 보면
password = event['protocolData']['mqtt']['password']
print("\n Base64 encoded Password is: " + password)
first_decoded = base64.b64decode(password).decode('utf-8')
print("\n First Base64 decoded Password is: " + first_decoded)
decoded_password = base64.b64decode(first_decoded).decode('utf-8')
print("\n Second Base64 decoded Password is: " + decoded_password)
보면 event 안에 있는 'protocolData'에 있는 'mqtt'에서 'password' 값을 가져와서 base64로 디코딩을 하여 jwt 토큰을 추출하는 것이다.
{
"isAuthenticated":true, //A Boolean that determines whether client can connect.
"principalId": "xxxxxxxx", //A string that identifies the connection in logs.
"disconnectAfterInSeconds": 86400,
"refreshAfterInSeconds": 300,
"policyDocuments": [
{
"Version": "2012-10-17",
"Statement": [
{
"Action": "iot:Publish",
"Effect": "Allow",
"Resource": "arn:aws:iot:us-east-1:<your_aws_account_id>:topic/customauthtesting"
}
]
}
]
}
그리고 Custom Authorizer를 성공하게 된다면 위와 같이 작성된 내용을 AWS IoT Core에 반환하게 된다. 여기에 보면 policyDocuments라는 부분에 AWS IoT Core와 관련된 정책이 설정되어 반환이 된다.
def buildPolicyDocument(client_id):
return {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"iot:Receive",
"iot:Publish"
],
"Resource": [
"arn:aws:iot:us-east-1:253475213230:topic/basestation/sensor/data"
]
},
{
"Effect": "Allow",
"Action": [
"iot:Subscribe"
],
"Resource": [
"arn:aws:iot:us-east-1:253475213230:topicfilter/basestation/sensor/data"
]
},
{
"Effect": "Allow",
"Action": [
"iot:Connect"
],
"Resource": f"arn:aws:iot:us-east-1:253475213230:client/{client_id}"
}
]
}
그래서 Lambda의 코드에 이런 메서드를 만든 것이고 이 메서드를 통해 클라이언트가 Custom Authorizer를 통해 인증을 통과하면 이런 정책을 부여받아 AWS IoT Core의 MQTT 토픽에 연결될 수 있도록 설정하는 것이다.
이렇게 되면 문제가 없는가? 아니다. 위에서 Custom Authorizer를 생성할 때 언급한 문제를 일으켰던 토큰 키 문제가 아니더라도 추가로 확인해야 하는 것이 있다.
Custom Authorizer를 생성한다고 끝이 아니라 이 처럼 AWS Docs에는 AWS IoT Core 서비스에 명시적으로 권한을 부여하여 사용자를 대신하여 함수를 호출할 수 있도록 설정해야 한다는 내용이 있다. 이 내용은 별도로 분리해서 표시한 것이 아니라 문단과 문단 사이 가장 마지막에 위치해 넘어갈 수 있는 내용이라 찾는데 애를 먹었다. (그래서 AWS Docs를 잘 보아야 한다... 근데 찾기 어렵긴 어렵다.)
위에서는 AWS cli를 이용해 람다에 권한을 추가하는 것을 볼 수 있는데 이를 AWS 콘솔에서도 실행할 수 있다.
먼저 방금 생성한 IoT Core의 사용자 지정 권한 부여자의 ARN을 복사한다.
그다음 Custom Authorizer용으로 생성한 Lambda로 이동한다. 그 후 구성에서 권한을 선택한다. 아래로 스크롤하다 보면 리소스 기반 정책 설명이라는 탭이 있다.
먼저 AWS 서비스를 선택하고 서비스로 AWS IoT를 선택한다. DemoID001이라고 문 ID를 설정했는데 이 값은 원하는 대로 설정하시면 된다. 보안 주체는 따로 건들지 않고 소스 ARN에 복사해 온 권한부여자의 ARN을 입력한 뒤 작업으로 lambda:invokeFunction을 선택한 뒤 저장을 한다. 이 뒤에 실제 동작하는 코드를 작성해야 하는데 내용이 많으니 1부 2부로 나누어 코드와 실행 결과는 2부에서 다루겠다.
4. 실제 도입 당시 있었던 문제와 해결에 대해서
그렇다면 위에서 계속 언급한 Custom Authorizer 생성 시 토큰 키를 설정해서 발생한 문제에 대해 설명하겠다.
위 이미지는 내가 실제 생성했던 Custom Auhtorizer이다. 보면 토큰키 이름에 authorization이라고 지정되어 있다. Custom Authorizer가 실행되지 않는 문제가 있었던 당시 혹시나 하고 봤던 설정 중에 토큰 키 이름이 있는 것을 발견했고 이를 편집을 눌러 토큰 키 이름을 제거했다.
이와 같이 제거하도록 업데이트를 했지만 아래 이미지처럼 업데이트 했다고 하는데 토큰 키 이름이 삭제되지 않는다.
이후에도 수십번 해당 업데이트를 해 보았지만 토큰 키 이름이 제거되는 일이 없었다. 내가 보기엔 이건 AWS에 있는 버그 같다는 생각이 들어 이 후 관련 내용을 AWS에 전달할 예정이다.
어쨌든 토큰 키 이름이 좀비처럼 살아서 다시 생겨나는 이 현상을 발견했고 이후 혹시나 해서 새로운 Custom Authorizer를 생성하니 드디어 Custom Authorizer의 호출에 성공한 것이다. 당시에 클라이언트가 IoT Core Custom Authorizer로 MQTT 토픽에 연결된 것은 아니지만 Cloud Watch에 Lambda의 Log가 기록된 것을 확인하고 AWS IoT Core의 Cloud Watch에도 Custom Authorizer 수행 이 후 MQTT 연결 실패를 했다는 Log가 기록된 것만 해도 매우 환호했다. 2일간 무엇이 문제인지 AWS IoT Core Docs를 따라 해도 제대로 실행되지 않던 것이 사실 실수로 등록했던 토큰 키 이름 때문이었다는 것을 안 순간 허탈함과 동시에 그래도 하나의 문제를 해결했다는 안도와 희열을 잊지 못할 것 같다.
이렇게 AWS IoT Core의 Custom Authorizer를 생성해 보았다. AWS IoT Core Custom Authorizer의 실습을 위한 모의 기기 코드와 실행 결과는 2부에서 다루겠다.
추천글
AWS IoT Core 개발자 가이드 : https://docs.aws.amazon.com/iot/latest/developerguide/config-custom-auth.html
'클라우드 > [AWS] AWS IoT Core' 카테고리의 다른 글
AWS IoT Core Fleet Provisioning을 구현해보자 (1) | 2024.09.24 |
---|---|
AWS IoT Core Device Shadow를 알아보자 (1) | 2024.09.20 |
AWS IoT Core Custom Authorizer 적용해보자 2부 (1) | 2024.09.03 |