토큰 기반 인증

11/13/2024

토큰 기반 인증이란?

토큰 기반 인증은 사용자가 로그인하면 서버가 토큰(Token)을 발급하고, 클라이언트는 이 토큰을 저장한 후 요청할 때 마다 이를 서버로 보내어 인증을 받는 방식이다. 이 방식은 Stateless(무상태)한 인증 방식으로, 서버가 사용자의 세션 상태를 기억할 필요가 없다.


동작 원리

  1. 로그인 요청: 사용자가 자격 증명(예: 사용자 이름, 비밀번호)을 서버에 제출한다.
  2. 토큰 발급: 서버는 자격 증명이 유효하면, 클라이언트에게 JWT(JSON Web Token)와 같은 토큰을 발급한다.
  3. 토큰 저장: 클라이언트는 이 토큰을 브라우저의 로컬 스토리지나 쿠키에 저장한다.
  4. 요청 시 토큰 전송: 이후 클라이언트는 모든 요청에 이 토큰을 HTTP 헤더에 포함하여 서버로 전송한다.
  5. 서버에서 토큰 검증: 서버는 전송된 토큰의 유효성을 확인하고, 유효한 경우 요청을 처리한다.
  6. 토큰 만료/재발급: 토큰이 만료되면, 클라이언트는 새로 로그인하거나 리프레시 토큰(refresh token)을 사용해 새로운 액세스 토큰(access token)을 발급받는다.

JWT(JSON Web Token)

JWT(JSON Web Token)는 JSON 형식으로 데이터를 안전하게 주고 받기 위한 웹 표준이다. JWT는 크게 세 부분으로 나뉜다.

  1. Header (헤더): 토큰의 타입(JWT)과 서명에 사용할 해싱 알고리즘(예: HS256)을 정의한다.
  2. Payload (페이로드): 사용자 정보나 기타 데이터를 담고 있으며, 이는 암호화되지 않은 상태로 Base64로 인코딩 된다.
  3. Signature (서명): 헤더와 페이로드를 조합하고 비밀키로 서명한 값이다. 이 서명을 통해 데이터의 무결성을 검증할 수 있다.

JWT 구조

  • https://jwt.io/

위 링크로 이동하면 JWT의 형태를 볼 수 있다. JSON 웹 토큰은 헤더, 페이로드, 서명 세 부분으로 구성된다. 헤더와 페이로드는 Base64로 인코딩된 다음 마침표로 연결된다.

header.payload.signature

// 토큰 전체
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 // 여기가 헤더

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
// 여기까지가 페이로드

SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c // 서명 부분이다

토큰 기반 인증의 장단점

장점

  • 무상태(Stateless): 서버가 세션 상태를 유지할 필요가 없으므로 확장성(Scalability)에 유리하다.
  • 다양한 플랫폼에서 사용 가능: 모바일 앱, 웹 애플리케이션 등 다양한 환경에서 쉽게 사용할 수 있다.
  • 보안성 강화: 각 요청마다 토큰이 포함되므로, 세션 탈취 공격(Session Hijacking)을 줄일 수 있다.

단점

  • 보안 문제: JWT가 탈취되면 만료될 때까지 악용될 수 있으므로 안전하게 저장해야 한다.
  • 토큰 크기 문제: JWT는 서명과 페이로드를 포함하므로 쿠키보다 크기가 커질 수 있으며, 대역폭을 더 많이 차지할 수 있다.
  • 만료된 토큰 처리 복잡성: 만료된 토큰을 처리하는 로직이 복잡할 수 있다.

코드 예시

설정 및 패키지 설치

mkdir jwt-auth-example 
cd jwt-auth-example 
npm init -y 
npm install express jsonwebtoken body-parser
  • express: Node.js에서 서버를 쉽게 구축할 수 있는 프레임워크
  • jsonwebtoken: JWT 생성 및 검증을 위한 라이브러리
  • body-parser: POST 요청에서 보낸 데이터를 파싱하기 위한 미들웨어

app.js

const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');

const app = express();
app.use(bodyParser.urlencoded({ extended: true }));

const SECRET_KEY = 'mySecretKey'; // JWT 서명에 사용할 비밀키

// 간단한 사용자 데이터베이스 예시
const users = {
	'user1': 'password1',
	'user2': 'password2'
};

  

// 로그인 페이지 (GET 요청)
app.get('/login', (req, res) => {
	res.send(`
		<h2>로그인</h2>
		<form method="POST" action="/login">
			<label>사용자 이름:</label>
			<input type="text" name="username" />
			<label>비밀번호:</label>
			<input type="password" name="password" />
			<button type="submit">로그인</button>
		</form>
	`);
});

  

// 로그인 처리 (POST 요청)
app.post('/login', (req, res) => {
	const { username, password } = req.body;

	// 사용자 인증 확인
	if (users[username] && users[username] === password) {
		// JWT 생성 (유효기간 1시간)
		const token = jwt.sign({ username }, SECRET_KEY, { expiresIn: '1h' });
		res.json({ token });
	} else {
		res.status(401).send('로그인 실패! 사용자 이름 또는 비밀번호가 잘못되었습니다.');
	}
});

// 보호된 라우트 (JWT 검증)
app.get('/dashboard', (req, res) => {
	const token = req.headers['authorization'];

	if (!token) {
		return res.status(403).send('토큰이 필요합니다.');
	}

	// JWT 검증
	jwt.verify(token, SECRET_KEY, (err, decoded) => {
		if (err) {
			return res.status(401).send('유효하지 않은 토큰입니다.');
		}

		// 유효한 토큰일 경우 사용자 정보 제공
		res.send(`환영합니다, ${decoded.username}님!`);
	});
});

  

// 서버 실행
app.listen(3000, () => {
	console.log('서버가 http://localhost:3000 에서 실행 중입니다.');
});

실행

node app.js
  • 미리 정의된 사용자 정보(user1, password1) 을 로그인 폼에 입력
  • 토큰이 화면에 표시
  • 표시된 토큰을 https://jwt.io/ 링크를 통해 디코딩하면 정보를 확인할 수 있음

토큰 저장

실제 애플리케이션에서는 이 토큰을 로컬 스토리지나 쿠키에 저장하여 이후 요청 시 사용할 수 있도록 한다.

localStorage.setItem('token', token);

보호된 경로 접근 시 토큰 전송

클라이언트는 보호된 경로(예: dashboard)에 접근할 때마다 이 토큰을 HTTP 요청의 Authorization 헤더에 포함시켜 서버로 전송해야 한다.

저장 및 대시보드 이동 예시

서버 코드(app.js)
const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');

const app = express();
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(express.static('public')); // 정적 파일 제공을 위해 public 폴더 사용

const SECRET_KEY = 'mySecretKey'; // JWT 서명에 사용할 비밀키

// 간단한 사용자 데이터베이스 예시
const users = {
	'user1': 'password1',
	'user2': 'password2'
};

// 로그인 페이지 (GET 요청)
app.get('/login', (req, res) => {
	res.sendFile(__dirname + '/public/login.html'); // 로그인 페이지 제공
});

// 로그인 처리 (POST 요청)
app.post('/login', (req, res) => {
	const { username, password } = req.body;

	// 사용자 인증 확인
	if (users[username] && users[username] === password) {
		// JWT 생성 (유효기간 1시간)
		const token = jwt.sign({ username }, SECRET_KEY, { expiresIn: '1h' });
		res.json({ token }); // JWT 토큰 반환
	} else {
		res.status(401).send('로그인 실패! 사용자 이름 또는 비밀번호가 잘못되었습니다.');
	}
});

// 보호된 라우트 (JWT 검증)

app.get('/dashboard', (req, res) => {
	const authHeader = req.headers['authorization'];

	if (!authHeader) {
		return res.status(403).send('토큰이 필요합니다.');
	}

	const token = authHeader.split(' ')[1];

	// JWT 검증
	jwt.verify(token, SECRET_KEY, (err, decoded) => {
		if (err) {
			return res.status(401).send('유효하지 않은 토큰입니다.');
		}

		// 유효한 토큰일 경우 사용자 정보 제공
		res.send(`환영합니다, ${decoded.username}님!`);
	});
});

// 서버 실행
app.listen(3000, () => {
	console.log('서버가 http://localhost:3000 에서 실행 중입니다.');
});
로그인페이지(public/login.html)
<!DOCTYPE html> 
<html lang="en"> 
<head> 
	<meta charset="UTF-8"> 
	<meta name="viewport" content="width=device-width, initial-scale=1.0"> 
	<title>로그인</title> 
</head> 

<body> 
	<h2>로그인</h2> 
	<form id="loginForm"> 
		<label>사용자 이름:</label> 
		<input type="text" id="username" /> 
		<label>비밀번호:</label> 
		<input type="password" id="password" /> 
		<button type="submit">로그인</button> 
	</form> 
	
	<script> 
		document.getElementById('loginForm').addEventListener('submit', async function(event) { 
		event.preventDefault(); 
		const username = document.getElementById('username').value; 
		const password = document.getElementById('password').value; 
		
		try { 
			const response = await fetch('/login', { 
				method: 'POST', 
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({ username, password }) 
			}); 
			
			if (!response.ok) { 
				throw new Error('로그인 실패'); 
			} 
			
			const data = await response.json(); 
			
			// JWT를 로컬 스토리지에 저장 - 추가된 부분 
			localStorage.setItem('token', data.token); 
			alert('로그인 성공! 대시보드로 이동합니다.'); 
			// 대시보드로 이동 - 추가된 부분 
			
			window.location.href = '/dashboard.html'; 
		} catch (error) { 
			alert(error.message); 
		} 
	}); 
	</script> 

</body> 
</html>
대시보드페이지(public/dashboard.html)
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>대시보드</title>
</head>

<body>
	<h2>대시보드</h2>
	<p id="welcomeMessage"></p>

	<script>
		async function loadDashboard() {
		const token = localStorage.getItem('token'); // 로컬 스토리지에서 토큰 가져오기

		if (!token) {
			alert('로그인이 필요합니다.');
			window.location.href = '/login.html';
			return;
		}

		try {
			const response = await fetch('/dashboard', {
				method: 'GET',
				headers: {
					'Authorization': 'Bearer ' + token // Authorization 헤더에 토큰 추가
				}
			});

			if (!response.ok) {
				throw new Error('대시보드 접근 실패');
			}

			const message = await response.text();
			document.getElementById('welcomeMessage').innerText = message;
		} catch (error) {
			alert(error.message);
		}
	}
	loadDashboard();
	</script>

</body>
</html>
Bearer란

dashboard.html 을 보면 headers의 토큰 앞에 Bearer를 추가한 것을 볼 수 있다.


Bearer

Bearer는 OAuth 2.0 인증 프레임워크에서 사용하는 토큰 인증 방식 중 하나다. 이 방식에서 Bearer 토큰은 보호된 리소스에 접근할 수 있는 권한을 부여하는 엑세스 토큰의 일종이다.

Bearer의 의미

  • Bearer는 "소유자"라는 뜻이다. 즉, Bearer 토큰은 "이 토큰을 소유한 사람에게 권한을 부여해줘"라는 의미를 내포하고 있다.
  • Bearer 토큰은 클라이언트가 서버에 요청을 보낼 때, HTTP 요청의 Authorization헤더에 포함되어 전송된다.

헤더에서 Bearer 사용

  • HTTP 요청에서 Bearer 토큰을 전송할 때, Authorization 헤더에 다음과 같은 형식으로 포함한다.
Authorization: Bearer <token>

예시

fetch('/dashboard', { 
	method: 'GET', 
	headers: { 
		'Authorization': 'Bearer ' + localStorage.getItem('token') 
	} 
})

Bearer 사용하는 이유

  • Bearer는 서버에서 "이 토큰을 가진 사람은 인증된 사용자이므로, 이 사용자가 보호된 리소스에 접근할 수 있도록 해달라"는 의미를 전달한다.
  • 이 방식은 OAuth 2.0에서 주로 사용되며, 클라이언트가 서버에 다시 로그인하지 않고도 보호된 리소스에 접근할 수 있도록 한다.

사실 Bearer 없어도 됨

  • Bearer 없이도 JWT를 사용할 수 있지만, 일반적으로 OAuth 2.0 및 여러 표준에서는 Bearer 방식을 권장한다.
  • Bearer 없이도 JWT를 Authorization 헤더에 포함시켜 서버로 전송할 수 있지만, 보안 표준과 명확한 의사소통을 위해 Bearer 방식을 사용하는 것이 더 안전하고 일관성 있는 방법이다.

서버 측 코드

서버 측에서 Bearer를 이용한 JWT를 검증할 때, Authorization 헤더에서 Bearer 토큰을 추출하여 검증한다. 이 과정에서 헤더가 올바르게 전달되지 않으면 인증 실패로 처리된다.

// Authorization 헤더 가져오기
const authHeader = req.headers['authorization']; 

// Bearer 뒤의 실제 토큰만 추출
const token = authHeader.split(' ')[1];

위 코드에서 authHeader.split(' ')[1]Authorization 헤더에서 "Bearer"를 제외한 실제 JWT 토큰 부분만 추출하는 코드이다.

그렇기에 "Bearer " 뒤에 한 칸의 공백을 반드시 포함시켜야 한다.


블로그 내 관련 문서


참고 자료

출처 :

댓글을 불러오는 중...