[TAROYAKI] Ep13-1. 로그인 시스템 호스팅 (Backend)

Yihoon·2025년 5월 5일

TAROYAKI

목록 보기
15/20
post-thumbnail

11편에서 언급했듯 로그인 인프라를 Flask 서버로 마이그레이션하고자 했음. Flask 서버의 역할들을 모두 APIGW와 Lambda로 옮기고, 세션 저장은 서버 대신 DynamoDB를 사용하는 초안을 설계했었지만 다음 두 가지 이유로 해당안은 폐기되었다.

  • 에필로그에서도 다루겠지만, 해당 구조를 마이그레이션하는 데에는 Claude의 조언을 많이 얻었다. 물론 견고한 아키텍처임에는 틀림없지만 공부를 좀 더 하다 보니 해당 아키텍처는 로컬에서 포팅할 때의 아키텍처를 그대로 옮기는 데에만 충실했던 나머지 클라우드에서 최적의 구조가 아니었다. Cognito와 바로 교환이 가능한 정보를 굳이 APIGW-Lambda를 거치다 보니 불필요한 흐름이 오가고 코드가 복잡해졌다.
  • 이를 인지하고 있음에도 아키텍처를 새로 설계할 엄두가 나지 않던 와중, 어느 시점부터 액세스 토큰 교환은 성공하지만 그 후로 Cognito로부터 아무런 토큰과 정보를 받아오지 못하는 문제가 생겼다. APIGW 쪽에서 문제가 생긴 것 같아서 동작 설정, SPA 라우팅 등 여러 해결책을 시도해봤지만 아무것도 문제해결에 도움이 되지 않았다.

거의 두 달을 이 문제에 시달리다가 결국 아키텍처를 재설계했다.
수정된 아키텍처는 이전과 마찬가지로 세션 + JWT 하이브리드 구조를 가진다.
세션 저장 위치는 우선 DynamoDB에서 로컬 스토리지로 변경했다. 이 경우 토큰이 탈취당할 위험이 높아지는 문제가 있기에 토큰 유효시간을 X분으로 짧게 저장하였다. 이후 이를 보안 쿠키를 활용하는 방법으로 개선하고자 한다.

프론트 코드가 긴 관계로 백엔드 부분만 먼저 다루겠다.

Backend

백엔드 아키텍처는 다음과 같다.

API Gateway

기존의 토큰 교환 로직은 모두 Cognito 엔드포인트로 바로 연결하고, 사용자 정보 교환을 위해서만 사용한다.
보안상의 이유로 별도의 REST API를 만들고, 엔드포인트는

/userinfo
	GET

하나만 존재한다.

마찬가지로 프록시 통합을 수행하고 응답 헤더를 Lambda에서 정의하고자 한다.

Lambda

구조도에는 나타나있지 않지만, 앞에서 말한 APIGW와 연결하여 사용자 정보를 교환하는 함수이다.
먼저 jwt 토큰의 payload 부분을 디코딩한다.

import json
import base64
import boto3

def decode_jwt_payload(token):
    try:
        payload = token.split('.')[1]
        payload += '=' * (4 - len(payload) % 4) # base64 디코딩을 위한 padding
        decoded = base64.b64decode(payload)
        return json.loads(decoded)
    except Exception as e:
        raise ValueError(f"Invalid token format: {str(e)}")

이어서 CUP에서 사용자 정보를 가져오는 함수를 만들었다. boto3에서 cognito IdP 개체를 만들고 요청을 보내 사용자 정보를 추출, 딕셔너리 형태로 리턴한다.

def get_user_attributes(username, user_pool_id):
    cognito = boto3.client('cognito-idp')
    try:
        response = cognito.admin_get_user(
            UserPoolId=user_pool_id,
            Username=username
        )
        attributes = {attr['Name']: attr['Value'] for attr in response['UserAttributes']}
        return attributes
    except Exception as e:
        raise ValueError(f"Error fetching user attributes: {str(e)}")

이 함수들을 바탕으로 한 lambda handler의 동작은 주석으로 기술.

def lambda_handler(event, context):
    try:
        token = event['headers'].get('Authorization')
        
        # 토큰이 없으면 401 반환
        if not token:
            return {
                'statusCode': 401,
                'body': json.dumps({'message': 'No authorization token provided'})
            }
        # jwt에서 payload를 디코딩
        decoded = decode_jwt_payload(token)
        
        # 디코딩된 토큰으로 사용자 정보(cognitot상에서의 username) 조회
        username = decoded.get('cognito:username')
        
        #CUP에서 해당 username의 속성(이름, 이메일, sub값) 로드
        user_pool_id = 'us-east-1_XXXXXX'
        user_attributes = get_user_attributes(username, user_pool_id)
        
        user_info = {
            'name': username,
            'email': decoded.get('email'),
            'nickname': user_attributes.get('nickname')
        }
        
        return {
            'statusCode': 200,
            'headers': {
                'Access-Control-Allow-Origin': 'https://xxxxxx.cloudfront.net',
                'Access-Control-Allow-Headers': 'Content-Type,Authorization',
                'Access-Control-Allow-Methods': 'GET,OPTIONS'
            },
            'body': json.dumps(user_info)
        }
    
    except Exception as e:
        return {
            'statusCode': 500,
            'body': json.dumps({'message': str(e)})
        }

IAM

해당 Lambda 함수에는 cloudfront를 통한 모니터링을 위한 권한, 그리고 cognito idp에 대해 사용자 정보를 얻을 수 있는 권한을 부여하기 위해 아래의 정책 구문을 연결하였다.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:CreateLogGroup",
                "logs:PutLogEvents"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "cognito-idp:AdminGetUser",
                "cognito-idp:GetSigningCertificate"
            ],
            "Resource": "*"
        }
    ]
}

다음 편에서 계속

profile
딴짓 좋아하는 데이터쟁이

0개의 댓글