콘텐츠로 이동

인증과 보안 — 내 데이터를 지키는 방법

아직 해결 안 된 문제

7장에서 만든 Todo API는 동작하지만 치명적인 문제가 있습니다.

GET  /todos        → 모든 사람의 할 일이 보임
DELETE /todos/5    → 누구든 삭제 가능

실제 서비스에서는 "내 데이터만 볼 수 있어야" 합니다.
이를 위해 인증(Authentication)인가(Authorization)가 필요합니다.

인증 vs 인가

두 개념은 자주 혼동되지만 다릅니다.

인증 (Authentication) — "당신이 누구인가?"
  → 로그인 과정. 이 사람이 정말 지민인지 확인.

인가 (Authorization) — "당신이 이것을 해도 되는가?"
  → 권한 확인. 지민이 하나의 할 일을 삭제할 수 있는가?
비유:
인증 = 신분증 확인 (당신이 누구인지)
인가 = 탑승권 확인 (이 비행기에 탈 수 있는지)

비밀번호는 절대 평문으로 저장하지 않는다

가장 먼저 알아야 할 규칙입니다.

# 절대 하면 안 되는 것
user = {"email": "[email protected]", "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를 코드에 하드코딩