인증과 보안 — 내 데이터를 지키는 방법¶
아직 해결 안 된 문제¶
7장에서 만든 Todo API는 동작하지만 치명적인 문제가 있습니다.
GET /todos → 모든 사람의 할 일이 보임
DELETE /todos/5 → 누구든 삭제 가능
실제 서비스에서는 "내 데이터만 볼 수 있어야" 합니다.
이를 위해 인증(Authentication)과 인가(Authorization)가 필요합니다.
인증 vs 인가¶
두 개념은 자주 혼동되지만 다릅니다.
인증 (Authentication) — "당신이 누구인가?"
→ 로그인 과정. 이 사람이 정말 지민인지 확인.
인가 (Authorization) — "당신이 이것을 해도 되는가?"
→ 권한 확인. 지민이 하나의 할 일을 삭제할 수 있는가?
비유:
인증 = 신분증 확인 (당신이 누구인지)
인가 = 탑승권 확인 (이 비행기에 탈 수 있는지)
비밀번호는 절대 평문으로 저장하지 않는다¶
가장 먼저 알아야 할 규칙입니다.
# 절대 하면 안 되는 것
user = {"email": "jimin@ex.com", "password": "mypassword123"}
db.save(user) # 비밀번호가 그대로 저장됨
# DB가 털리면 모든 사용자의 비밀번호가 노출
해싱 (Hashing)¶
비밀번호를 저장하기 전에 해시 함수로 변환합니다.
"mypassword123" → 해시 함수 → "$2b$12$Kf3..."
특성:
- 원문 → 해시: 쉬움
- 해시 → 원문: 불가능 (단방향)
- "mypassword123"은 항상 같은 해시를 생성
- "mypassword124"는 완전히 다른 해시를 생성
bcrypt 사용하기¶
pip install bcrypt
import bcrypt
# 비밀번호 저장 (회원가입)
password = "mypassword123"
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
# hashed를 DB에 저장
# 비밀번호 검증 (로그인)
entered = "mypassword123"
is_valid = bcrypt.checkpw(entered.encode(), hashed)
# True → 일치, False → 불일치
gensalt()가 매번 다른 솔트(Salt)를 생성합니다.
같은 비밀번호라도 해시 값이 달라져서 레인보우 테이블 공격을 막습니다.
세션 기반 인증 vs JWT¶
로그인 후 "이 사람이 맞다"는 사실을 어떻게 유지할까요?
HTTP는 무상태(Stateless)라서 요청마다 이 사실을 알려줘야 합니다.
세션 기반 인증¶
로그인 성공
↓
서버가 세션 ID 생성 → DB에 저장
↓
클라이언트에게 세션 ID를 쿠키로 전달
↓
이후 요청마다 쿠키의 세션 ID 포함
↓
서버가 DB에서 세션 ID를 조회해 사용자 확인
장점: 서버가 세션을 즉시 무효화 가능 (로그아웃, 강제 탈퇴)
단점: 요청마다 DB 조회 필요 / 서버 여러 대면 세션 공유 문제
JWT (JSON Web Token)¶
로그인 성공
↓
서버가 JWT 생성 (사용자 정보 + 만료 시간 + 서명)
↓
클라이언트에게 JWT 전달 (클라이언트가 보관)
↓
이후 요청마다 JWT를 헤더에 포함
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
↓
서버가 서명을 검증하여 사용자 확인 (DB 조회 없음)
장점: DB 조회 없음 / 서버 여러 대라도 문제 없음
단점: 토큰을 즉시 무효화하기 어려움 (만료 시간까지 유효)
JWT 구조¶
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxfQ.abc123
─────────────────── ──────────────────── ──────
헤더 페이로드 서명
(알고리즘 정보) (담긴 데이터) (위변조 방지)
페이로드는 Base64로 인코딩되어 있어 누구나 읽을 수 있습니다.
서명은 비밀 키로 만들어서 서버만 검증할 수 있습니다.
JWT 페이로드에 비밀 정보(비밀번호, 결제 정보 등)를 담으면 안 됩니다.
user_id처럼 식별에 필요한 최소한의 정보만 담으세요.
JWT 구현 — FastAPI¶
pip install python-jose[cryptography] passlib[bcrypt]
# auth.py
from datetime import datetime, timedelta
from jose import jwt, JWTError
from passlib.context import CryptContext
SECRET_KEY = "your-secret-key-keep-this-safe" # 환경 변수로 관리할 것
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
pwd_context = CryptContext(schemes=["bcrypt"])
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(user_id: int) -> str:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": str(user_id), "exp": expire}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> int:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return int(payload["sub"])
회원가입 / 로그인 엔드포인트¶
# main.py (추가)
from fastapi import Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from auth import hash_password, verify_password, create_access_token, decode_token
security = HTTPBearer()
class UserCreate(BaseModel):
email: str
password: str = Field(min_length=8)
name: str
class UserLogin(BaseModel):
email: str
password: str
@app.post("/auth/signup", status_code=201)
def signup(body: UserCreate):
conn = get_db()
existing = conn.execute("SELECT id FROM users WHERE email = ?", (body.email,)).fetchone()
if existing:
conn.close()
raise HTTPException(status_code=400, detail="이미 사용 중인 이메일입니다")
hashed_pw = hash_password(body.password)
cursor = conn.execute(
"INSERT INTO users (email, password, name) VALUES (?, ?, ?)",
(body.email, hashed_pw, body.name)
)
conn.commit()
user_id = cursor.lastrowid
conn.close()
token = create_access_token(user_id)
return {"access_token": token, "token_type": "bearer"}
@app.post("/auth/login")
def login(body: UserLogin):
conn = get_db()
row = conn.execute("SELECT * FROM users WHERE email = ?", (body.email,)).fetchone()
conn.close()
if row is None or not verify_password(body.password, row["password"]):
raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다")
token = create_access_token(row["id"])
return {"access_token": token, "token_type": "bearer"}
인증이 필요한 엔드포인트 — 현재 사용자 가져오기¶
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
token = credentials.credentials
try:
user_id = decode_token(token)
except Exception:
raise HTTPException(status_code=401, detail="유효하지 않은 토큰입니다")
conn = get_db()
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
conn.close()
if row is None:
raise HTTPException(status_code=401, detail="사용자를 찾을 수 없습니다")
return dict(row)
# 로그인한 사용자만 자신의 할 일 조회
@app.get("/todos")
def get_todos(current_user: dict = Depends(get_current_user)):
conn = get_db()
rows = conn.execute(
"SELECT * FROM todos WHERE user_id = ? ORDER BY created_at DESC",
(current_user["id"],)
).fetchall()
conn.close()
return [dict(row) for row in rows]
# 삭제 시 본인 것인지 확인
@app.delete("/todos/{todo_id}")
def delete_todo(todo_id: int, current_user: dict = Depends(get_current_user)):
conn = get_db()
row = conn.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
if row is None:
conn.close()
raise HTTPException(status_code=404, detail="Not found")
if row["user_id"] != current_user["id"]:
conn.close()
raise HTTPException(status_code=403, detail="권한이 없습니다")
conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
conn.commit()
conn.close()
return {"deleted": todo_id}
환경 변수로 비밀 값 관리¶
SECRET_KEY, DB 비밀번호 같은 민감한 값은 코드에 직접 쓰면 안 됩니다.
# 나쁜 예 — 코드에 하드코딩
SECRET_KEY = "my-super-secret-key"
# 좋은 예 — 환경 변수에서 읽기
import os
SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY:
raise RuntimeError("SECRET_KEY 환경 변수가 설정되지 않았습니다")
# 실행 시
SECRET_KEY=my-super-secret-key uvicorn main:app
# 또는 .env 파일에 작성 (pip install python-dotenv)
# .env 파일은 반드시 .gitignore에 추가
SECRET_KEY=my-super-secret-key
# .gitignore
.env
*.db
주요 보안 취약점 — OWASP Top 10 중에서¶
1. SQL 인젝션 (SQL Injection)¶
7장에서 다뤘지만 가장 중요한 취약점입니다.
# 위험
conn.execute(f"SELECT * FROM users WHERE email = '{email}'")
# 공격자 입력: ' OR '1'='1
# 실행: SELECT * FROM users WHERE email = '' OR '1'='1'
# → 전체 사용자 정보 노출
# 안전
conn.execute("SELECT * FROM users WHERE email = ?", (email,))
2. 인증 누락 (Broken Authentication)¶
# 위험 — 누구나 삭제 가능
@app.delete("/todos/{id}")
def delete(id: int):
...
# 안전 — 로그인 확인 + 본인 확인
@app.delete("/todos/{id}")
def delete(id: int, user = Depends(get_current_user)):
# 본인 것인지 확인 후 삭제
...
3. 민감 데이터 노출¶
# 위험 — 비밀번호가 응답에 포함
@app.get("/users/me")
def me(user = Depends(get_current_user)):
return user # password 포함!
# 안전 — 필요한 필드만 반환
@app.get("/users/me")
def me(user = Depends(get_current_user)):
return {"id": user["id"], "email": user["email"], "name": user["name"]}
4. CORS — 교차 출처 리소스 공유¶
프론트엔드(예: http://localhost:3000)와 백엔드(예: http://localhost:8000)가 다른 주소일 때 브라우저가 요청을 차단합니다.
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"], # 허용할 출처
allow_methods=["*"],
allow_headers=["*"],
)
운영 환경에서
allow_origins=["*"](모든 출처 허용)는 위험합니다.
실제 프론트엔드 도메인만 명시하세요.
인증 흐름 전체 정리¶
[회원가입]
클라이언트 서버 DB
│ │ │
│ POST /auth/signup │ │
│ {email, password, name} ─▶│ │
│ │ 비밀번호 해싱 │
│ │ INSERT INTO users ... ──▶│
│ │ │
│ │ JWT 생성 │
│ ◀─ {access_token: "..."} ─│ │
[로그인 후 API 호출]
클라이언트 서버 DB
│ │ │
│ GET /todos │ │
│ Authorization: Bearer ... ▶│ │
│ │ 토큰 검증 (DB 조회 없음) │
│ │ user_id 추출 │
│ │ SELECT * FROM todos ... ─▶│
│ │ │
│ ◀─ [{id:1, title:"..."}, ─│ │
│ {id:2, title:"..."} ] │ │
실습 미션¶
미션 1: 비밀번호 해싱¶
1. bcrypt로 비밀번호를 해싱하는 함수를 작성하세요.
2. 해싱한 값이 매번 달라지는지 확인하세요 (솔트 때문).
3. verify 함수로 올바른 비밀번호와 틀린 비밀번호를 각각 검증하세요.
미션 2: 회원가입 / 로그인 API¶
1. POST /auth/signup 엔드포인트를 구현하세요.
2. POST /auth/login 엔드포인트를 구현하세요.
3. 이미 존재하는 이메일로 가입 시 400을 반환하세요.
4. 틀린 비밀번호로 로그인 시 401을 반환하세요.
미션 3: 인증 미들웨어 적용¶
1. get_current_user 함수를 작성하세요.
2. GET /todos, POST /todos, DELETE /todos/{id}에 인증을 적용하세요.
3. DELETE 시 본인의 할 일만 삭제 가능하게 하세요. 다른 사람 것이면 403.
4. 토큰 없이 요청하면 401을 반환하는지 확인하세요.
미션 4 (심화): 토큰 갱신¶
access token은 만료 시간이 짧아야 안전합니다 (예: 15분).
하지만 15분마다 다시 로그인하면 불편합니다.
refresh token 방식을 조사하고 구현해 보세요:
- access token: 15분 유효, API 호출에 사용
- refresh token: 7일 유효, access token 재발급에 사용
핵심 요약¶
| 개념 | 설명 |
|---|---|
| 인증 (Authentication) | 사용자가 누구인지 확인 — 로그인 |
| 인가 (Authorization) | 무엇을 할 수 있는지 확인 — 권한 검사 |
| 해싱 | 단방향 변환 — 비밀번호를 복원 불가능하게 저장 |
| bcrypt | 비밀번호 해싱 표준 알고리즘 (솔트 자동 포함) |
| JWT | 서버가 DB 조회 없이 검증 가능한 토큰 |
| 파라미터 바인딩 | SQL 인젝션 방지 — ? 플레이스홀더 |
| 환경 변수 | SECRET_KEY 등 민감한 값을 코드 밖에서 관리 |
| CORS | 다른 출처 브라우저 요청 허용 설정 |
절대 하면 안 되는 것 세 가지
1. 비밀번호를 평문으로 저장
2. SQL을 문자열 포매팅으로 조합
3. SECRET_KEY를 코드에 하드코딩