본문 바로가기

Untagged

JWT와 회원가입 예제(FastAPI, React)

728x90

이 포스트에서는 회원가입을 구현하는 과정을 통해 JWT를 사용해 보고 이해해 보도록 한다.

JWT(Json Web Token)

위키피디아에서는 다음과 같이 설명하고 있다.

 

JWT는 JSON 형식의 데이터를 안전하게 주고받기 위한 인터넷 표준이며, 선택적 서명이나 암호화를 포함할 수 있다.

 

JWT는 클레임(claims)이라는 정보를 담고 있으며, 이는 어떤 사실을 주장하는 형태를 띠고 있다.

 

예를 들어, 서버가 "관리자 로그인함"이라는 클레임을 담은 토큰을 클라이언트한테 전달하면, 클라이언트는 이 토큰을 서버나 제삼자에 전달하여 인증된 사용자임을 증명한다.

 

JWT는 짧고 URL-safe 하며, 특히 웹 기반의 SSO(Single Sign On) 시나리오에 적합하다.

 

JWT는 JSON Web Signature(JWS), JSON Web Encryption(JWE) 등의 표준 위에 구축되어 있다.

 

그러니까 JSON 형식으로 담긴 데이터에 서명을 추가하여 위조를 막은 토큰이라 보면 된다.

JWT의 구조

JWT는 다음 구조로 데이터를 저장한다.

Header.Payload.Signature

 

JS로는 다음과 같이 생성할 수 있을 것이다.

const token = base64urlEncoding(header) + '.' + base64urlEncoding(payload) + '.' + base64urlEncoding(signature)

 

위의 코드에도 알 수 있듯이, JWT는 단순히 그 내용을 base64로 인코딩하므로, 즉 쉽게 원래 내용으로 디코딩이 가능하므로 토큰 내에 중요한 정보를 담아서는 안된다.

회원가입에서의 흐름 - 리액트와 FastAPI로 보는

실제로 회원가입 기능을 구현하면서 JWT를 발급해 보도록 하자. 그리고 이 JWT로 인증을 받아 보호된 페이지로 접근하는 기능을 구현해 보자. 프론트는 리액트로, 백엔드는 fastAPI로 진행한다.

FastAPI

우선 백엔드는 다음과 같이 되어있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
from passlib.context import CryptContext
import jwt
from datetime import datetime, timedelta
from fastapi.middleware.cors import CORSMiddleware
 
 
app = FastAPI()
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],  # React 주소
    allow_methods=["POST""GET"],  # ✅ POST 허용
    allow_headers=["*"],
)
 
# todo: PostgreSQL로 바꾸기
fake_db = {}
 
pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
 
SECRET_KEY = 'your-secret-key'
ALGORITHM = 'HS256'
ACCESS_TOKEN_EXPIRE_MINUTES = 30
 
class User(BaseModel):
    username: str
    password: str
 
class UserInDB(BaseModel):
    username: str
    hashed_password: str
 
# jwt 토큰 생성
def create_access_token(data: dict):
    to_encode = data.copy()
    expire = datetime.now() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
 
# 비밀번호 해싱
def get_password_hash(password):
    return pwd_context.hash(password)
 
# 사용자 검증
def authenticate_user(username: str, password: str):
    user = fake_db.get(username)
    if not user or not pwd_context.verify(password, user.hashed_pssword):
        return False
    return user
 
@app.post('/signup')
async def signup(user: User):
    if user.username in fake_db:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="이미 존재하는 아이디입니다."
        )
 
    hashed_password = get_password_hash(user.password)
    fake_db[user.username] = UserInDB(
        username=user.username,
        hashed_password=hashed_password
    )
 
    return {'msg''회원가입 성공'}
 
 
# 로그인 엔드포인트 (JWT 발급)
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    user = fake_db.get(form_data.username)
    if not user or not pwd_context.verify(form_data.password, user.hashed_password):
        raise HTTPException(status_code=400, detail="잘못된 아이디/비밀번호")
 
    access_token = create_access_token(data={"sub": user.username})
    return {"access_token": access_token, "token_type""bearer"}
 
@app.get('/protected')
async def protected_route(token: str = Depends(OAuth2PasswordBearer(tokenUrl='login'))):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        return {'username': payload['sub'], 'msg'"인증 성공"}
    except jwt.PyJWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail='유효하지 않은 토큰입니다.'
        )
 
 
if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, host='0.0.0.0')
cs

 

 

스웨거로 나타나는 라우트를 간략히 설명해 보자.

 

/signup: 프론트엔드에서 받은 id와 비밀번호를 받아 비밀번호를 해싱하고 저장한다.

 

/token: 아이디와 비밀번호를 서버로 전송하여 서버에서는 실제 존재하는 유저인지 확인 후, JWT 토큰을 발급해 준다.

 

/protected: JWT 토큰을 가지고 있는 유저에게만 접근이 허락되는 페이지이다. 여기서 JWT가 유효한지 인증한 결과를 반환한다.

 

각각의 구현을 보도록 하자.

 

signup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@app.post('/signup')
async def signup(user: User):
    if user.username in fake_db:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="이미 존재하는 아이디입니다."
        )
 
    hashed_password = get_password_hash(user.password)
    fake_db[user.username] = UserInDB(
        username=user.username,
        hashed_password=hashed_password
    )
 
    return {'msg''회원가입 성공'}
cs

 

중복되지 않는 아이디와 비밀번호를 받아 DB에 등록해 주는 간단한 일을 한다.

 

token

1
2
3
4
5
6
7
8
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    user = fake_db.get(form_data.username)
    if not user or not pwd_context.verify(form_data.password, user.hashed_password):
        raise HTTPException(status_code=400, detail="잘못된 아이디/비밀번호")
 
    access_token = create_access_token(data={"sub": user.username})
    return {"access_token": access_token, "token_type""bearer""username": user.username}
cs

 

유저를 검증한 뒤, 30분 간 사용할 수 있는 JWT를 반환한다.

 

protected

1
2
3
4
5
6
7
8
9
10
@app.get('/protected')
async def protected_route(token: str = Depends(OAuth2PasswordBearer(tokenUrl='login'))):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        return {'username': payload['sub'], 'msg'"인증 성공"}
    except jwt.PyJWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail='유효하지 않은 토큰입니다.'
        )
cs

 

발급받은 토큰을 검증한다. 토큰의 검증은 jwt.decode()를 통해 이루어지며, 여기서 문제가 발생하면 HTTPException을 발생시킨다.

 

즉, JWT는 이미 가입한 회원에 대해 토큰을 발급하고, 접근 제한이 필요한 페이지에서 JWT를 인증하여 인증된 사용자만 접근할 수 있도록 해준다.

React

프론트엔드에서는 복잡한 기능이 필요한 것은 아니다.

 

App.tsx -  회원가입, 로그인 폼 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
import { useState } from 'react'
import { BrowserRouter as Router, Routes, Route, Link, useNavigate } from 'react-router-dom';
import ProtectedPage from './pages/ProtectedPage';
import './App.css'
 
 
function LoginForm({ onLogin }: { onLogin: (token: string) => void }) {
  const [id, setId] = useState('');
  const [password, setPassword] = useState('');
  const navigate = useNavigate();
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
 
    try {
      const response = await fetch('http://localhost:8000/token', {
        method: "POST",
        headers: {'Content-Type''application/x-www-form-urlencoded'},
        body: `username=${id}&password=${password}`
      });
 
      if (response.ok === false) {
        const errorData = await response.json();
        alert(`로그인 실패: ${errorData.detail}`);
        return;
      }
 
      const data = await response.json();
      onLogin(data.access_token);
      navigate('/protected');
 
    } catch (err) {
      alert('로그인 실패!');
    }
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <h2>로그인</h2>
      <input type="text" value={id} onChange={(e) => setId(e.target.value)} placeholder='아이디'/>
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder='비밀번호'/>
      <button type="submit">로그인</button>
      <p>아직 회원이 아니신가요? <Link to ="/signup">회원가입</Link></p>
    </form>
  );
}
 
function SignupForm() {
  const [id, setId] = useState('');
  const [password, setPassword] = useState('');
  const navigate = useNavigate();
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      const response = await fetch('http://localhost:8000/signup', {
        method: 'POST'
        headers: {'Content-Type''application/json'},
        body: JSON.stringify({username: id, password}),
      });
 
      if (response.ok) {
        alert('회원가입 성공! 로그인해주세요.');
        navigate('/');
      } else {
        alert('회원가입 실패!');
      }
    } catch (err) {
      console.error('Error: ', err);
    }
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <h2>회원가입</h2>
      <input type='text' value={id} onChange={(e) => setId(e.target.value)} placeholder='아이디'/>
      <input type='password' value={password} onChange={(e) => setPassword(e.target.value)} placeholder='비밀번호'/>
      <button type='submit'>가입하기</button>
      <p>
        이미 회원이신가요?<Link to='/'>로그인</Link>
      </p>
    </form>
  )
}
 
function App() {
  const [token, setToken] = useState('');
 
  const handleLogin = (newToken: string) => {
    setToken(newToken);
    localStorage.setItem('token', newToken);
  }
 
  return (
    <Router>
      <nav>
        <Link to='/'></Link>
      </nav>
      <Routes>
        <Route path='/' element={<LoginForm onLogin={handleLogin} />/>
        <Route path='/signup' element={<SignupForm />/>
        <Route path='/protected' element={<ProtectedPage />/>
      </Routes>
    </Router>
  )
}
 
export default App
 
cs

 

ProtectedPage.tsx - 보호된 페이지 이동

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { useEffect, useState } from 'react';
 
export default function ProtectedPage() {
    const [data, setData] = useState('');
 
    useEffect(() => {
        const token = localStorage.getItem('token');
        console.log(token);
        if (!token) return;
 
        fetch('http://localhost:8000/protected', {
            headers: {
                'Authorization': `Bearer ${token}`
            }
        }).then(res => res.json())
          .then(data => setData(JSON.stringify(data, null2)));
          
    }, []);
 
    return (
        <div>
            <h2>보호된 페이지</h2>
            <pre>{data || '토큰 없음 또는 로딩 중...'}</pre>
        </div>
    )
}
cs

 

간단히 회원가입 폼, 로그인 폼, 보호된 페이지 3가지로 구성되어 있다.

 

ProtectedPage를 보면 로컬 스토리지에 저장된 토큰을 서버로 전송하여, 그 결과를 처리하는 모습을 볼 수 있다.

 

서버에서의 처리 결과에 따라 메시지를 볼 수 있다.

 

 

실제 개발에서는 인증받지 않은 유저는 회원 가입 라우트로 네비게이트를 하거나 로그인 후 이용하라는 알러트를 띄울 수 있을 것이다.

 

이렇게 JWT를 간단히 이해하고, FastAPI와 리액트로  JWT 인증을 수행하는 예제를 작성해보았다.

 

728x90