기존의 로그인 흐름은 보안 토큰을 URL에 넣어서 보내기 때문에 해당 URL만 확보하면 로그인이 가능한, 보안이 많이 취약한 구조였다. 보안 쿠키로 jwt verify하는 방법을 포함해서 여러 가지 인증 흐름을 급히 공부하고, 프론트를 열심히 뜯어고쳐 보았으나 어느 시점에서부터 로그인 후 리다이렉션이 불가능한 사태가 벌어졌다.

인프라가 단단히 꼬였다고 판단, 기존 인프라를 삭제하고, 보안 강화 전의 스택과 에러를 재배포했지만 그래도 에러는 전혀 해결되지 않았다.

URL에서 확인해 본 결과 Cognito의 Client ID가 구 버전 인프라를 참조하는 것을 확인할 수 있었다. 삭제되지 않은 인프라가 없는지 아무리 확인해 보아도 존재하지 않았다. 호스팅하기 전이기 때문에 그와 관련된 문제도 아니었고, 며칠동안 씨름해봐도 상황이 좀처럼 해결되지 않았다.
어차피 로그인 구현에 사용한 레포지토리 전반에 대한 이해가 거의 불가능했기에 과감하게 로그인 인프라를 완전히 새로 구현하기로 결정하였다.
일단 여기서 재설계한 로그인 흐름은 다음과 같다.

JWT 토큰을 활용하되 토큰 저장을 서버 세션에 저장하는 일종의 하이브리드 방법으로,
cognito와 연결하여 통신을 주고받는 부분은 APIGW + Flask 서버가 수행하고, 프론트엔드에 필요한 환경변수는 Express 서버가 전송하는 구조이다.
이러한 구조를 채택한 이유에 대해서는 할 이야기가 조금 있는 관계로 에필로그에서 따로 언급하겠다.
환경 변수 전달은 추후 SSM Parameter Store로, Flask 서버의 역할은 Lambda로 이관하여 호스팅하고자 한다. 물론 이 서버를 통째로 마이그레이션해도 되지만 최대한의 서버리스 아키텍처를 만드는 게 목표다.
일단 카카오 로그인을 연결하는 데는 여기를 참고했다. Kakao Login이 이제 OAUTH2 흐름을 지원하기 때문에 OIDC Identity Provider로 추가하여 간편하게 로그인 시스템을 구축할 수 있었다.
다음과 같이 IdP를 추가하였다.

포트 3000에서 Cognito와의 통신을 담당하는 flask 서버를 만들었다.
HTTPS로만 접근 가능한 세션 쿠키에 토큰을 저장하였다.
CORS설정으로 3000, 3001, 3002에서의 트래픽을 허용하도록 설정하였다.
from flask import Flask, jsonify, request, session
from flask_cors import CORS
import requests
import jwt
import base64
from datetime import datetime, timedelta
import pytz
from dotenv import load_dotenv
import os
load_dotenv() # 환경변수 로드
app = Flask(__name__)
CORS(app,
supports_credentials=True,
origins=['http://localhost:3000', 'http://localhost:3001', 'http://localhost:3002'],
allow_headers=['Content-Type'],
expose_headers=['Set-Cookie'])
.env에 저장한 환경 변수를 불러오고 다음과 같이 보안 옵션을 설정하였다.
app.secret_key = os.getenv('FLASK_SECRET_KEY')
app.config.update(
SESSION_COOKIE_SECURE=True, # HTTPS로만 쿠키 전송
SESSION_COOKIE_HTTPONLY=True, # JS에서는 쿠키 접근 불가
SESSION_COOKIE_SAMESITE='Lax', # CSRF 설정
SESSION_COOKIE_DOMAIN=None, # 도메인은 현재 도메인으로 제한
SESSION_COOKIE_PATH='/', # 전체 경로에서 접근 가능
PERMANENT_SESSION_LIFETIME=timedelta(days=1) # 로그인 세션의 최대 유효기간 1일
)
COGNITO_DOMAIN = os.getenv('COGNITO_DOMAIN')
CLIENT_ID = os.getenv('CLIENT_ID')
CLIENT_SECRET = os.getenv('CLIENT_SECRET')
REDIRECT_URI = os.getenv('REDIRECT_URI')
def get_current_time():
return datetime.now(pytz.UTC) #현재시간을 리턴
먼저 /auth/token 엔드포인트는 auth code를 Cognito에 요청하고, 필요한 정보 (ID token, Access token, Refresh token)를 반환/저장하는 역할을 한다.
@app.route('/auth/token', methods=['POST'])
def get_token():
try:
code = request.json.get('code')
if not code:
return jsonify({'error': 'No authorization code provided'}), 400
# cognito 토큰 엔드포인트에 요청
token_endpoint = f"{COGNITO_DOMAIN}/oauth2/token"
auth_header = base64.b64encode(
f"{CLIENT_ID}:{CLIENT_SECRET}".encode('utf-8')
).decode('utf-8')
headers = {
'Authorization': f'Basic {auth_header}',
'Content-Type': 'application/x-www-form-urlencoded'
}
data = {
'grant_type': 'authorization_code',
'client_id': CLIENT_ID,
'code': code,
'redirect_uri': REDIRECT_URI
}
response = requests.post(token_endpoint, headers=headers, data=data)
if not response.ok:
return jsonify({'error': 'Token exchange failed'}), response.status_code
# 응답에서 토큰 추출
tokens = response.json()
# ID 토큰에서 사용자 정보 추출
id_token = tokens.get('id_token')
user_info = jwt.decode(id_token, options={"verify_signature": False})
# 세션에 사용자 정보 저장
session.permanent = True
expiry_time = get_current_time() + timedelta(seconds=tokens['expires_in'])
# access token, refresh token 반환
session['tokens'] = {
'access_token': tokens['access_token'],
'refresh_token': tokens['refresh_token'],
'token_expiry': expiry_time.isoformat() # 토큰 만료 시간
}
# 필요한 정보 프론트엔드에 반환
session['user'] = {
'name': user_info.get('nickname') or user_info.get('email'),
'email': user_info.get('email'),
'sub': user_info.get('sub')
}
return jsonify({
'success': True,
'access_token': tokens['access_token'],
'expires_in': tokens['expires_in']
})
except Exception as e:
return jsonify({'error': str(e)}), 400
한편 /auth/refresh 메소드는 토큰 갱신을 담당한다.
먼저 세션에서 토큰을 가져오고,
@app.route('/auth/refresh', methods=['POST'])
def refresh_token():
try:
if 'tokens' not in session:
return jsonify({'error': 'No refresh token available'}), 401
Cognito 토큰 엔드포인트에 갱신 요청을 보낸다.
refresh_token = session['tokens'].get('refresh_token')
if not refresh_token:
return jsonify({'error': 'No refresh token found'}), 401
token_endpoint = f"{COGNITO_DOMAIN}/oauth2/token"
auth_header = base64.b64encode(
f"{CLIENT_ID}:{CLIENT_SECRET}".encode('utf-8')
).decode('utf-8')
headers = {
'Authorization': f'Basic {auth_header}',
'Content-Type': 'application/x-www-form-urlencoded'
}
여기서 Refresh token을 이용하여 새 액세스 토큰을 요청한다.
data = {
'grant_type': 'refresh_token',
'client_id': CLIENT_ID,
'refresh_token': refresh_token
}
response = requests.post(token_endpoint, headers=headers, data=data)
if not response.ok:
session.clear()
return jsonify({'error': 'Token refresh failed'}), response.status_code
new_tokens = response.json()
새 토큰과 만료시간으로 세션 정보를 업데이트한다.
session['tokens']['access_token'] = new_tokens['access_token']
session['tokens']['token_expiry'] = get_current_time() + timedelta(seconds=new_tokens['expires_in'])
return jsonify({
'success': True,
'access_token': new_tokens['access_token'],
'expires_in': new_tokens['expires_in']
})
except Exception as e:
session.clear()
return jsonify({'error': str(e)}), 400
다음으로 /api/user-info 엔드포인트는 사용자 정보를 받아 프론트에 보내는 역할을 한다.
먼저 세션에 사용자 정보가 있는지 확인한다.
@app.route('/api/user-info', methods=['GET'])
def get_user_info():
if 'user' not in session:
return jsonify({'error': 'Not authenticated'}), 401
if 'tokens' not in session:
session.clear()
return jsonify({'error': 'No valid tokens'}), 401
이어서 토큰이 만료되지 않았는지 먼저 확인하고 그렇지 않다면 사용자 정보를 프론트로 전달해 준다.
token_expiry_str = session.get('tokens', {}).get('token_expiry')
if token_expiry_str:
try:
token_expiry = datetime.fromisoformat(token_expiry_str)
if get_current_time() > token_expiry:
session.clear()
return jsonify({'error': 'Token expired'}), 401
except ValueError:
session.clear()
return jsonify({'error': 'Invalid token expiry format'}), 401
else:
session.clear()
return jsonify({'error': 'No token expiry time'}), 401
return jsonify(session['user'])
/auth/logout 엔드포인트는 로그아웃을 담당한다.
먼저 세션 정보를 삭제한다.
@app.route('/auth/logout', methods=['POST'])
def logout():
session.clear()
response = jsonify({'success': True})
이어서 세션 쿠키를 삭제한다.
response.set_cookie('session', '',
expires=0,
secure=True,
httponly=True,
samesite='Lax',
path='/')
return response
if __name__ == '__main__':
app.run(port=3000)
한편 포트 3002에서는 express 서버가 동작한다. 이는 환경 변수를 프론트엔드에 전송하는 역할을 한다.
import express from 'express';
import dotenv from 'dotenv';
import cors from 'cors';
dotenv.config();
먼저 CORS 설정으로 앱이 실행될 origin(현재 단계에서는 포트 3001)을 지정해 주었다.
const app = express();
const PORT = 3002;
app.use(cors({
origin: 'http://localhost:3001',
credentials: true
}));
다음으로 필요한 환경 변수를 전달한다.
app.get('/api/config', (req, res) => {
res.json({
message: 'Config fetched successfully',
apiUrl: process.env.API_URL,
wsUrl: process.env.WS_URL,
redUri: process.env.REDIRECT_URI,
cogDom: process.env.COGNITO_DOMAIN,
cliId: process.env.CLIENT_ID,
confURL: process.env.CONFIG_URL,
flaUrl: process.env.FLASK_URL,
logoutUri: process.env.LOGOUT_URI
});
});
app.use(express.static('public'));
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
포트 3001에서는 프론트 코드가 실행되고 있다. 주요 코드만 간단하게만 언급하겠다.
먼저 Express 서버에서 필요한 설정값을 가져온다.
async function fetchConfig() {
const response = await fetch('http://localhost:3002/api/config', {
credentials: 'include'
});
const config = await response.json();
API_URL = config.apiUrl;
WS_URL = config.wsUrl;
REDIRECT_URI = config.redUri;
// (이하 생략)
}
한편 페이지 초기화 시 아래의 인증 흐름을 구현하였다:
async function initializePage() {
await fetchConfig();
const code = getAuthorizationCode();
if (!code) {
try {
const response = await fetch(`${FLASK_URL}/api/user-info`, {
credentials: 'include'
});
if (response.ok) {
const userInfo = await response.json();
return;
}
} catch (error) {
redirectToLogin();
return;
}
}
try {
const tokenResponse = await exchangeCodeForTokens(code); // 토큰 교환
if (tokenResponse.success) {
setupTokenRefresh(tokenResponse.expires_in);
accessToken = tokenResponse.access_token;
// 사용자 이름 및 이메일 로드 (생략)
const userInfo = await fetchUserInfo();
if (userInfo) {
// 사용자 정보 있을 시 나머지 페이지 시작 로직 실행 (생략)
}
return;
}
}
} catch (error) {
console.error('Authentication error:', error);
redirectToLogin();
}
토큰 교환은 exchangeCodeForTokens 함수로 별도로 분리하였다.
COGNITO 로그인 결과로 받은 인증코드는 auth/token 에서 Flask 서버를 통해서 토큰으로 교환된다. 클라이언트는 여기서 access token과 만료 시간만 반환받고 나머지는 서버 세션에 저장된다.
async function exchangeCodeForTokens(code) {
const response = await fetch(`${FLASK_URL}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code })
});
return await response.json();
}
마지막으로 로그아웃 시스템은
먼저 /auth/logout 엔드포인트로 요청을 보내 백엔드 세션을 삭제한다.
async function logout() {
try {
await fetch(`${FLASK_URL}/auth/logout`, {
method: 'POST',
credentials: 'include'
});
다음으로 클라이언트 측 쿠키를 모두 삭제한다.
document.cookie.split(';').forEach(cookie => {
// 쿠키 삭제(생략)
});
로컬 스토리지와 세션 스토리지를 비운다.
localStorage.clear();
sessionStorage.clear();
Cognito 로그아웃 URL로 리다이렉션한다.
window.location.href = logoutUrl;
} catch (error) {
console.error('Logout failed:', error);
}
}