[TAROYAKI] Ep12. 로그인 호스팅 초안

Yihoon·2025년 5월 5일

TAROYAKI

목록 보기
14/20
post-thumbnail

이게 왜 '초안'이라는 이름이 붙냐면, 몇몇 이유들로 아키텍처를 폐기하고 전면 재설계하였기 때문이다.
그 이야기는 다음 편에서 다루기로 하고, 해당 아키텍처는 간단하게만 다루고 넘어가겠다.

Backend

여기서 구현된 아키텍처는 아래와 같다.Flask 서버가 담당하던 역할들을 모두 Lambda로 마이그레이션하고 환경변수는 SSM 파라미터 스토어가 관리하는 포맷. 그리고 세션은 DynamoDB에서 관리한다.

API Gateway

백엔드 로그인 아키텍처와 연결하기 위한 REST API를 하나 더 만들었다. 해당 API의 리소스는 다음과 같이 구성하였다.

/config: SSM으로부터 로그인에 필요한 파라미터 교환. tarotchat-confighandelr 함수와 연결
/user-info: 토큰 교환 후 사용자 정보 교환에 사용. tarotchat-userinfo 함수와 연결
/auth/logout: 로그아웃 시 토큰 및 사용자 정보 초기화. tarotchat-logout 함수와 연결
/auth/refresh: 토큰 만료 시 로그아웃 처리 tarotchat-tokenrefresh 함수와 연결
/auth/token: 로그인 시 토큰 인증에 사용. tarotchat-tokenexchange 함수와 연결

그리고 Cognito User Pool을 APIGW의 Authorizor로 등록, Claim을 통해 사용자 정보를 받고, 나아가 CUP가 Lambda에 대한 권한을 갖도록 하였다.

Lambda

해당 APIGW와 연결될 함수 코드들.
tarotchat-confighandelr.py
SSM 파라미터 스토어에서 저장된 파라미터들을 가져온다.

import json
import boto3
import os

def lambda_handler(event, context):
    try:
        ssm = boto3.client('ssm') # ssm 개체 설정
        params = ssm.get_parameters_by_path(
            Path='/tarot-chat/prod',
            WithDecryption=True
        )['Parameters'] #/tarot-chat/prod 디렉토리의 파라미터들을, 보안 문자열은 복호화하여 가져옴

# 불러온 value들에서 config 추출
        config = {
            'apiUrl': next(p['Value'] for p in params if p['Name'].endswith('/api-url')),
            'wsUrl': next(p['Value'] for p in params if p['Name'].endswith('/ws-url')),
            'cognitoDomain': next(p['Value'] for p in params if p['Name'].endswith('/cognito-domain')),
            'redirectUri': next(p['Value'] for p in params if p['Name'].endswith('/redirect-uri')),
            'clientId': next(p['Value'] for p in params if p['Name'].endswith('/client-id')),
        }
        
        return {
            'statusCode': 200,
            'headers': {
                'Access-Control-Allow-Origin': os.environ['ALLOWED_ORIGIN'],
                'Access-Control-Allow-Credentials': 'true'
            },
            'body': json.dumps(config)
        }
        
    except Exception as e:
        return {
            'statusCode': 500,
            'body': json.dumps({'error': str(e)})
        }

tarotchat-userinfo.py

import json
from auth_utils import *

def lambda_handler(event, context):
    
    # 응답에 사용할 CORS 헤더
    cors_headers = {
        'Access-Control-Allow-Origin': 'https://d256c0vgw8wwge.cloudfront.net',
        'Access-Control-Allow-Credentials': 'true',
        'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
        'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
        'Access-Control-Expose-Headers': 'Set-Cookie'
    }
    
# claims 추출: cognito 인증 후 사용자 정보가 포함    
    try:
        request_context = event.get('requestContext', {})
        print("Request context:", json.dumps(request_context, indent=2))
        
        authorizer = request_context.get('authorizer', {})
        print("Authorizer context:", json.dumps(authorizer, indent=2))
        
        claims = authorizer.get('claims', {})
        print("Claims:", json.dumps(claims, indent=2))

        if not authorizer:
            print("WARNING: No authorizer found in request context")
            return {
                'statusCode': 401,
                'headers': cors_headers,
                'body': json.dumps({'error': 'No authorization context'})
            }
            
        if not claims:
            print("WARNING: No claims found in authorizer context")
            return {
                'statusCode': 401,
                'headers': cors_headers,
                'body': json.dumps({'error': 'No claims found in authorizer context'})
            }
            
# claims에서 사용자 정보(userid(sub), 이메일, 사용자명) 추출
        user_info = {
            'sub': claims.get('sub'),
            'email': claims.get('email'),
            'name': claims.get('cognito:username')
        }

        return {
            'statusCode': 200,
            'headers': cors_headers,
            'body': json.dumps(user_info)
        }
    except Exception as e:
        print("ERROR:", str(e))
        return {
            'statusCode': 500,
            'headers': cors_headers,
            'body': json.dumps({'error': str(e)})
        }

tarotchat-tokenrefresh
토큰이 만료되면 트리거되어 refresh token을 통해 새 토큰을 갱신받는 역할.

import json
import time
from session_manager import SessionManager
from auth_utils import *

def lambda_handler(event, context):
    session_manager = SessionManager()
    
    try:
        # 이벤트에서 세션 ID 추출
        cookies = event.get('headers', {}).get('Cookie', '')
        session_id = next(
            (c.split('=')[1] for c in cookies.split(';') 
             if c.strip().startswith('sessionId=')),
            None
        )
        
        if not session_id:
            return create_auth_response({'error': 'No refresh token available'}, None)
        
        # 커스텀 정의한 session manager 모듈을 통해 현재 세션 정보 확보
        session = session_manager.get_session(session_id)
        if not session:
            return create_auth_response({'error': 'Invalid session'}, None)
        # 토큰에서 refresh token 추출    
        refresh_token = session['Tokens'].get('refresh_token')
        if not refresh_token:
            session_manager.delete_session(session_id)
            return create_auth_response({'error': 'No refresh token found'}, None)

        # refresh token을 기반으로 Cognito 토큰 리프레시
        token_endpoint = f"{COGNITO_DOMAIN}/oauth2/token"
        auth_header = base64.b64encode(
            f"{CLIENT_ID}:{CLIENT_SECRET}".encode('utf-8')
        ).decode('utf-8')
        
        response = requests.post(
            token_endpoint,
            headers={
                'Authorization': f'Basic {auth_header}',
                'Content-Type': 'application/x-www-form-urlencoded'
            },
            data={
                'grant_type': 'refresh_token',
                'client_id': CLIENT_ID,
                'refresh_token': refresh_token
            }
        )
        
        # 에러 처리
        if not response.ok:
            session_manager.delete_session(session_id)
            return create_auth_response({'error': 'Token refresh failed'}, None)

        new_tokens = response.json()
        
        # 세션의 토큰 정보 업데이트
        session_manager.update_tokens(session_id, {
            'access_token': new_tokens['access_token'],
            'refresh_token': refresh_token,  # 기존 refresh token 유지
            'token_expiry': int(time.time() + new_tokens['expires_in'])
        })
        
        return create_auth_response({
            'success': True,
            'access_token': new_tokens['access_token'],
            'expires_in': new_tokens['expires_in']
        }, session_id)
        
    except Exception as e:
        return create_auth_response({'error': str(e)}, None)

tarotchat-tokenexchange.py
Authorization code를 access token으로 교환하는 역할을 한다.

import json
import uuid
import time
from auth_utils import *
import base64
from jose import jwt
import requests
import traceback

def create_auth_response(body, cookies=None):
    response = {
        'statusCode': 200 if body.get('success') else 400,
        'headers': {
            'Access-Control-Allow-Origin': 'https://0000000000.cloudfront.net',
            'Access-Control-Allow-Credentials': 'true',
            'Access-Control-Allow-Methods': 'POST,OPTIONS',
            'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'
        },
        'body': json.dumps(body)
    }
    
    if cookies:
        response['headers']['Set-Cookie'] = cookies
        
    return response

def lambda_handler(event, context):
    print('Received event:', json.dumps(event, indent=2))
    print('Environment variables:', {
        'COGNITO_DOMAIN': COGNITO_DOMAIN,
        'CLIENT_ID': CLIENT_ID,
        'REDIRECT_URI': REDIRECT_URI
    })

    try:
        # 1. event 개체에서 요청 바디 파싱 & 검증
        if isinstance(event, dict):
            code = event.get('code')
            if not code and event.get('body'):
                body = json.loads(event.get('body')) if isinstance(event.get('body'), str) else event.get('body')
                code = body.get('code')
        else:
            code = None
            
        print('Extracted code:', code)
        
        if not code:
            return create_auth_response({'error': 'No authorization code provided'}, None)

        # 2. 토큰 교환 준비: Client ID, Client secret을 인코딩하여 헤더 생성
        token_endpoint = f"{COGNITO_DOMAIN}/oauth2/token"
        
        auth_header = base64.b64encode(auth_header_raw.encode('utf-8')).decode('utf-8')
        
        data = {
            'grant_type': 'authorization_code',
            'client_id': CLIENT_ID,
            'code': code,
            'redirect_uri': REDIRECT_URI
        }

        # 3. 토큰 교환 요청 (POST)
        try:
            response = requests.post(
                token_endpoint,
                headers={
                    'Authorization': f'Basic {auth_header}',
                    'Content-Type': 'application/x-www-form-urlencoded'
                },
                data=data
            )
            try:
                response_text = response.text
                
            except Exception as e:


            if not response.ok:
                error_detail = json.loads(response.text) if response.text else {}
                return create_auth_response({
                    'error': 'Token exchange failed',
                    'details': error_detail
                }, None)

        except requests.RequestException as e:
            return create_auth_response({'error': 'Failed to connect to Cognito'}, None)
            
         # 4. 응답에서 access token, id token, refresh token 추출
        try:
            tokens = response.json()
            print('Received tokens with keys:', list(tokens.keys()))
            
        except json.JSONDecodeError as e:
            print('Error parsing token response:', str(e))
            return create_auth_response({'error': 'Invalid token response'}, None)

        # 5. ID 토큰 JWT 형식으로 디코딩
        try:
            id_token = tokens.get('id_token')
            if not id_token:
                return create_auth_response({'error': 'No ID token in response'}, None)

            # id토큰에서 payload 분리
            id_token_parts = id_token.split('.')
            if len(id_token_parts) != 3:
                print('Invalid JWT format')
                return create_auth_response({'error': 'Invalid ID token format'}, None)
                
            # 페이로드 디코딩
            payload = id_token_parts[1]
            payload += '=' * ((4 - len(payload) % 4) % 4)  # 패딩
            # 디코딩한 json에서 사용자 정보 추출
            try:
                user_info = json.loads(base64.urlsafe_b64decode(payload).decode('utf-8'))
                print('Decoded user info:', {
                    'email': user_info.get('email'),
                    'sub': user_info.get('sub')[:5] + '...'
                })
            except Exception as e:
                print('Error decoding payload:', str(e))
                return create_auth_response({'error': 'Failed to decode token payload'}, None)

        except Exception as e:
            print('Error decoding ID token:', str(e))
            return create_auth_response({'error': 'Failed to decode ID token'}, None)

        # 6. 성공 응답
        response = {
            'success': True,
            'access_token': tokens['access_token'],
            'id_token': tokens['id_token'],
            'refresh_token': tokens['refresh_token'],
            'expires_in': tokens['expires_in']
        }
        
        return {
            'statusCode': 200,
            'headers': {
                'Access-Control-Allow-Origin': 'https://d256c0vgw8wwge.cloudfront.net',
                'Access-Control-Allow-Credentials': 'true',
                'Access-Control-Allow-Methods': 'POST,OPTIONS',
                'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'
            },
            'body': json.dumps(response)
        }
        
    except Exception as e:
        print('Unexpected error:', str(e))
        print('Traceback:', traceback.format_exc())
        return create_auth_response({'error': f'Internal server error: {str(e)}'}, None)

session_manager.py
로그아웃 및 토큰 리프레시에 쓰이는 커스텀 모듈로, 세션을 관리하는 DynamoDB와 연결되어 세션 생성과 확보, 토큰 업데이트 등의 기능을 담고 있다.

import boto3
import time

class SessionManager:
    def __init__(self):
        self.dynamodb = boto3.resource('dynamodb')
        self.table = self.dynamodb.Table('tarotchat_authsessions')
    
    # 세션 생성
    def create_session(self, session_id, user_info, tokens):
        expires_at = int(time.time() + (5 * 24 * 60 * 60))  # 세션 만료 시간 5일로 설정
        
        item = {
            'SessionId': session_id,
            'UserId': user_info['sub'],
            'UserInfo': user_info,
            'Tokens': tokens,
            'ExpiresAt': expires_at
        }
        
        self.table.put_item(Item=item)
        return session_id
    # 세션 생성
    def get_session(self, session_id):
        response = self.table.get_item(Key={'SessionId': session_id})
        return response.get('Item')
        
     # 세션 삭제   
    def delete_session(self, session_id):
        self.table.delete_item(Key={'SessionId': session_id})
        
    def update_tokens(self, session_id, new_tokens):
        self.table.update_item(
            Key={'SessionId': session_id},
            UpdateExpression='SET Tokens = :tokens',
            ExpressionAttributeValues={':tokens': new_tokens}
        )

IAM

위에서 만든 함수들이 사용할 tarotchat_auth_config_role 역할을 살펴보겠다.

tarotchat_auth_ddbpolicy는 Lambda 함수들이 DynaoDB와 자유롭게 소통할 수 있도록 하는 역할을 한다.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "dynamodb:PutItem",
                "dynamodb:DeleteItem",
                "dynamodb:GetItem",
                "dynamodb:UpdateItem"
            ],
            "Resource": [
                "arn:aws:dynamodb:us-east-1:000000000000:table/tarotchat_authsessions",
                "arn:aws:dynamodb:us-east-1:000000000000:table/tarotchat_authsessions/*"
            ]
        }
    ]
}

tarotchat_auth_cwpolicy는 해당 함수들을 cloudwatch logs로 모니터링하기 위한 정책들을 담았으며

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        }
    ]
}

tarotchat_auth_ssmpolicy는 해당 함수들이 (추후 구현할) SSM 파라미터 스토어에서 변수를 불러올 수 있도록 하는 정책들이 담겨 있다.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ssm:GetParameter",
                "ssm:GetParameters",
                "ssm:GetParametersByPath"
            ],
            "Resource": [
                "arn:aws:ssm:us-east-1:000000000000:parameter/tarot-chat/prod/*",
                "arn:aws:ssm:us-east-1:000000000000:parameter/tarot-chat/prod"
            ]
        }
    ]
}

tarotchat-auth-cognito 정책은 Cognito User Pool에서 사용자 정보 목록에 접근할 수 있도록 한다.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Statement1",
            "Effect": "Allow",
            "Action": [
                "cognito-idp:ListUsers",
                "cognito-idp:GetUser"
            ],
            "Resource": "arn:aws:cognito-idp:us-east-1:*:userpool/us-east-1_ofS2k3zkI"
        }
    ]
}

SSM

상술한 바와 같이 Parameter store에 필요한 환경 변수들(APIGW URL 세 개,cognito user pool 도메인, redirect uri, cognito user pool client id)를 모두 저장하였다.

  • 사진 속 상태는 로그인 흐름을 재설계하면서 파라미터를 재업로드한 것으로 조금의 차이가 존재한다.

DynamoDB

TBD

Frontend

JWT 기반 인증으로, 프론트를 stateless로 유지하도록 했다.

Service.js

먼저 서비스 시작 시 인증 상태를 확인하고 리셋한다. URL에서 인증코드를 확인하고, 토큰으로 교환한 후 (유효하다면)사용자 정보를 가져오는 역할을 한다.

async function initializePage() {
  // 환경 설정 검증
  await validateConfig();
  
  // URL에서 인증 코드 추출
  const code = getAuthorizationCode();
  
  if (!code) {
    // 코드가 없으면 로그인 페이지로 리다이렉트
    redirectToLogin();
    return;
  }
  
  // 코드가 있으면 토큰으로 교환
  const tokenResponse = await exchangeCodeForTokens(code);
  
  // 토큰 저장
  localStorage.setItem('accessToken', tokenResponse.access_token);
  localStorage.setItem('refreshToken', tokenResponse.refresh_token);
  localStorage.setItem('tokenExpiry', Date.now() + (tokenResponse.expires_in * 1000));
  
  // 사용자 정보 가져오기
  const userInfo = await fetchUserInfo();
  updateUserInfo(userInfo);
  
  // 보안을 위해 URL에서 인증 코드 제거
  window.history.replaceState({}, document.title, window.location.pathname);
}

아래 함수는 API 요청 전에 토큰이 유효한지 체크하고 만료된 경우 새로 갱신할 수 있도록 한다.

async function validateTokenBeforeRequest() {
  const token = localStorage.getItem('accessToken');
  const expiry = localStorage.getItem('tokenExpiry');
  
  if (!token || !expiry || Date.now() >= parseInt(expiry)) {
    // 토큰 갱신
    await refreshTokens();
  }
  return localStorage.getItem('accessToken');
}

참고로 토큰 갱신 함수는 아래와 같다:

async function refreshTokens() {
  const refreshToken = localStorage.getItem('refreshToken');
  if (!refreshToken) {
    throw new Error('No refresh token available');
  }

  try {
    const response = await fetch(`${FLASK_URL}/auth/refresh`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ refresh_token: refreshToken })
    });

    if (response.ok) {
      const data = await response.json();
      
      // 전역 변수와 localStorage 모두 업데이트
      accessToken = data.access_token;
      tokenExpiryTime = Date.now() + (data.expires_in * 1000);
      
      localStorage.setItem('accessToken', data.access_token);
      localStorage.setItem('tokenExpiry', tokenExpiryTime.toString());
      
      return data.access_token;
    } else {
      throw new Error('Token refresh failed');
    }
  } catch (error) {
    localStorage.clear();
    redirectToLogin(); // 로그아웃 리다이렉트
    throw error;
  }
}

한편 api 호출의 경우 아래 함수를 만들어, 모든 요청에 API 인증 토큰을 포함하도록 하였다.

async function apiCall(url, options = {}) {
  try {
    const token = await validateTokenBeforeRequest();
    if (!token) {
      throw new Error('No access token available');
    }

    const response = await fetch(url, {
      ...options,
      credentials: 'include',
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${token}`,
        'Accept': 'application/json'
      }
    });

    if (response.status === 401) {
      // 토큰 갱신 시도
      const newToken = await refreshTokens();
      // 갱신된 토큰으로 재시도
      return fetch(url, {
        ...options,
        credentials: 'include',
        headers: {
          ...options.headers,
          'Authorization': `Bearer ${newToken}`,
          'Accept': 'application/json'
        }
      });
    }

    return response;
  } catch (error) {
    console.error('API call failed:', error);
    throw error;
  }
}

아래 함수는 백엔드 세션 종료는 물론 쿠키와 로컬스토리지를 모두 정리하고 리다이렉션한다.

async function logout() {
  try {
    // 백엔드 세션 클리어
    await apiCall(`${FLASK_URL}/auth/logout`, {
      method: 'POST',
      credentials: 'include'
    });

    // 쿠키 삭제
    document.cookie.split(';').forEach(cookie => {
      const cookieName = cookie.split('=')[0].trim();
      document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/;`;
      document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/tarot;`;
    });

    // 로컬 스토리지 및 세션 스토리지 클리어
    localStorage.clear();
    sessionStorage.clear();

    // Cognito 글로벌 로그아웃으로 리다이렉트
    const logoutUrl = `${COGNITO_DOMAIN}/logout?` +
      `client_id=${CLIENT_ID}&` +
      `logout_uri=${encodeURIComponent(REDIRECT_URI)}&` +
      `response_type=code&` +
      `redirect_uri=${encodeURIComponent(REDIRECT_URI)}&` +
      `global=true`;

    window.location.href = logoutUrl;
  } catch (error) {
    console.error('Logout failed:', error);
  }
}
profile
딴짓 좋아하는 데이터쟁이

0개의 댓글