3. 세션 기반 인증

(1) 이해

1) HTTP 기본 인증

인증의 필요성이 인식된 이후 초기 웹에서는 먼저 HTTP 기본 인증(Basic Authentication, BA) 방식이 사용되었다. 기본 인증은 클라이언트가 보호된 리소스에 접근할 때 HTTP 헤더에 Authorization: Basic base64(username:password)을 포함해 전송하는 인증 방식이다. Basic은 인증 타입을 나타낸다. 그리고 base64(username:password)은 사용자 이름과 비밀번호를 Base64로 인코딩한 문자열을 의미하는데, 이를 인증 정보, 또는 크리덴셜이라고 부른다. 서버는 이 크리덴셜을 검증해 인증을 수행하고, 브라우저는 크리덴셜을 캐싱하여 후속 요청에 자동으로 포함시킨다.
# HTTP 기본 인증 예시 Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
이러한 기본 인증 방식은 구현이 단순한 만큼 여러 보안 위험에 노출되어 있다. 브라우저에 캐시된 크리덴셜이 로컬 시스템에서 탈취될 수 있고, Base64 인코딩은 쉽게 디코딩할 수 있어 크리덴셜이 공격자에게 유출될 가능성이 높다. 또한 로그아웃 기능 구현이 어려워 브라우저 창을 닫기 전까지는 캐시 데이터가 로컬 시스템에 계속해서 남아있게 되므로, 공유 컴퓨터나 공공 장소에서 사용 시 보안 취약점이 될 수 있다.

2) 세션 기반 인증

위와 같은 배경에서 세션 기반 인증 방식이 등장했고, 기본 인증 방식의 여러 취약점을 보완해주었다. 세션(Session)이란 서버와 클라이언트 간의 연결 상태를 말한다. 세션 기반 인증에서는 사용자가 로그인에 성공하면 서버가 해당 사용자를 위한 세션 객체를 생성하고, 고유한 세션 ID를 발급한다. 세션 객체는 사용자 식별자, 로그인 시간, 만료 시간 등의 정보를 포함하며, 서버의 메모리나 데이터베이스 등에 저장된다. 세션 ID는 클라이언트로 전달되어 이후의 요청에 포함되며, 서버는 이를 통해 사용자를 식별한다.
이렇게 서버 측에서 상태를 저장하는 방식(stateful)은 로컬 시스템에서의 정보 탈취 위험을 줄여주었다. 또한 서버에서 세션 ID의 권한 관리와 유효성을 관리하여 세밀한 접근 제어, 즉각적인 로그아웃 기능 구현이 가능하다. 세션 ID는 암호학적으로 안전하게 생성되어 Base64 인코딩의 취약점을 극복하고, HTTP 통신에서 쿠키를 통해 전송되므로 민감 정보가 직접 노출되지 않는다.
반면 서버의 역할이 커지면서 발생하는 단점들도 있는데, 서버가 모든 사용자의 세션 정보를 기억해야 하므로, 사용자가 많아질수록 서버의 메모리 부하가 증가한다는 것이다. 그리고 여러 서버를 사용하는 분산 환경에서는 세션 정보의 동기화 문제가 발생할 수 있다. 더불어 CSRF(Cross-Site Request Forgery) 공격에 취약할 수 있어, 이에 대한 별도의 보안 대책이 필요하다.
 

(2) 프론트엔드의 세션 관리와 보안

세션 기반 인증에서 프론트엔드의 주요 역할은 사용자에게 인터페이스를 제공하여 크리덴셜을 수집하고, 서버와의 통신을 관리하는 것이다. 여기서 세션 ID는 사용자의 인증 상태를 나타내는 핵심 정보이므로, 이를 안전하게 저장하고 전송하는 것이 중요한 보안 요소이다.

1) 세션 ID 저장

쿠키에 저장

쿠키는 세션 ID를 저장하는 가장 일반적인 방법이며, 후술할 localStorage, sessionStorage 사용보다 보안적으로도 권장된다. 이 방법을 선택하면, 서버 측에서 전달된 쿠키는 브라우저의 쿠키 저장소에 자동으로 저장되므로 프론트엔드에서 직접 수행할 작업은 없다. 백엔드에서 쿠키를 생성할 때는 보안 강화를 위해 다음과 같이 HttpOnly, Secure, SameSite 플래그 설정을 고려할 수 있다.
/* 서버 측 코드 */ const express = require('express'); const session = require('express-session'); // express 앱의 세션 관리용 라이브러리 const app = express(); // 서버 앱 생성 app.use(session({ // 세션 미들웨어 설정 cookie: { httpOnly: true, // HttpOnly 플래그 secure: true // Secure 플래그 sameSite: 'strict', // sameSite 속성 } }));

localStorage와 sessionStorage

이 두 방법은 JavaScript를 통해 쉽게 접근할 수 있어 편리하지만, 그만큼 XSS 공격에 취약하다. 또한 HTTPS로 암호화되지 않고, 쿠키와 같은 보안 플래그도 설정할 수 없으므로 권장되지 않는다.
// localStorage 사용 예시 localStorage.setItem('sessionId', 'abc123'); // sessionStorage 사용 예시 sessionStorage.setItem('sessionId', 'abc123');

2) 세션 ID 전송

쿠키에 저장된 세션 ID는 HTTP 요청에 자동으로 포함되나 교차 출처의 경우 추가적인 설정이 필요하다.
// XMLHttpRequest 사용시 const xhr = new XMLHttpRequest(); xhr.open('GET', 'https://cross-origin.com/', true); xhr.withCredentials = true; // 쿠키 포함 설정 xhr.send();
// Fetch API 사용시 fetch('https://cross-origin.com/', { mode: 'cors', credentials: 'include' // 쿠키 포함 설정 });
// Axios 사용시 axios.get('https://cross-origin.com/', { withCredentials: true // 쿠키 포함 설정 });

3) 세션 ID 삭제

서비스에서 사용자가 로그아웃 시에는 세션 ID를 삭제하고 세션을 종료해야 한다. 이 과정은 주로 서버 측에서 처리되며, 클라이언트는 이를 요청하고 후속 작업을 처리한다.
  • 클라이언트 측 처리
    • fetch('/logout', { method: 'POST', credentials: 'include' }) // 서버에 로그아웃 요청 .then(response => { if (!response.ok) { throw new Error('Logout failed'); } // 로그아웃 성공 시 로컬 데이터 삭제 sessionStorage.removeItem("isLoggedIn"); sessionStorage.removeItem("username"); // UI 업데이트 등 추가 처리 }) .catch(error => { console.error("Logout failed:", error); });
  • 서버 측 처리
    • 세션 저장소에서 해당 세션 ID 제거
    • 새로운 요청에 대해 세션 ID가 더 이상 유효하지 않음을 확인
 

(3) 실습

1) 세션 기반 인증시 고려 사항

  • 로그인 구현시 고려 사항
    • 사용자 입력은 서버로 보내기 전 클라이언트 측에서 우선 검증한다.
    • 공용 컴퓨터에서의 보안을 위해 autocomplete="off"자동 완성을 방지한다.
  • 로그아웃 구현시 고려 사항
    • 클라이언트에서 로그아웃 요청을 보내 서버 측 세션을 무효화한다.
    • 로그아웃 요청 성공시 로컬에 저장된 세션 관련 모든 데이터를 삭제한다.
    • 로그아웃 후 사용자를 안전한 페이지로 리디렉트하는 방법도 권장된다.

2) 프론트엔드 예시 코드

아래 코드는 HTML과 JavaScript를 사용하여 위 고려 사항을 충족하는 세션 기반 인증을 구현한 예시이다. 로그인과 로그아웃 기능으로 간단한 로그인 폼과 사용자 정보 UI를 표시하고 있다. HTTP 통신에는 Axios를 사용하며, 백엔드 API로 /check-session, /login, /logout이 구현되어 있다고 가정한다.
<!DOCTYPE html> <html lang="ko"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>세션 기반 인증 예시</title> <script src="https://unpkg.com/axios/dist/axios.min.js"></script> </head> <body> <div id="auth-container"> <form id="login-form"> <input type="text" id="username" placeholder="username" autocomplete="off" required /> <input type="password" id="password" placeholder="password" autocomplete="off" required /> <button type="submit">로그인</button> </form> </div> <div id="user-info" style="display: none"> <p>안녕, <span id="user-name"></span>!</p> <button id="logout-btn">로그아웃</button> </div> <script src="app.js"></script> </body> </html>
// app.js const API_URL = "https://api.example.com"; // DOM 요소 참조 const authContainer = document.getElementById("auth-container"); const loginForm = document.getElementById("login-form"); const userInfo = document.getElementById("user-info"); const userNameSpan = document.getElementById("user-name"); const logoutBtn = document.getElementById("logout-btn"); // Axios 설정 axios.defaults.withCredentials = true; // 모든 요청에 쿠키 포함 설정 // 로그인 처리 async function login(username, password) { try { const response = await axios.post(`${API_URL}/login`, { username, password, }); localStorage.setItem('isLoggedIn', true); // 로컬에 세션 관련 데이터 저장 showUserInfo(response.data.username); // UI 업데이트 } catch (error) { console.error("로그인 실패:", error); alert("로그인에 실패했습니다. 사용자 이름과 비밀번호를 확인해주세요."); } } // 로그아웃 처리 async function logout() { try { await axios.post(`${API_URL}/logout`); // 서버로 로그아웃 요청 localStorage.removeItem('isLoggedIn'); // 로컬에 세션 관련 데이터 삭제 showLoginForm(); // UI 업데이트 window.location.href = '/login.html'; // 안전한 페이지로 리디렉트 } catch (error) { console.error("로그아웃 실패:", error); } } // UI 업데이트: 사용자 정보 표시 function showUserInfo(username) { authContainer.style.display = "none"; userInfo.style.display = "block"; userNameSpan.textContent = username; } // UI 업데이트: 로그인 폼 표시 function showLoginForm() { authContainer.style.display = "block"; userInfo.style.display = "none"; loginForm.reset(); } // 입력 값 밸리데이션 function validateInput(username, password) { if (username.length < 3) { alert("사용자 이름은 3자 이상이어야 합니다."); return false; } if (password.length < 8) { alert("비밀번호는 8자 이상이어야 합니다."); return false; } return true; } // 이벤트 리스너 loginForm.addEventListener("submit", async (e) => { e.preventDefault(); const username = document.getElementById("username").value; const password = document.getElementById("password").value; if (validateInput(username, password)) { await login(username, password); } }); logoutBtn.addEventListener("click", logout); // 세션 상태 확인 async function checkSession() { try { const response = await axios.get(`${API_URL}/check-session`); if (response.data.isLoggedIn) { localStorage.setItem('isLoggedIn', 'true'); showUserInfo(response.data.username); } else { localStorage.removeItem('isLoggedIn'); showLoginForm(); } } catch (error) { console.error("세션 확인 실패:", error); localStorage.removeItem('isLoggedIn'); showLoginForm(); } } checkSession(); // 페이지 로드, 새로고침 시 상태 유지