LLM 엔지니어링13장 · REST API 와 FastAPI19 / 22← 이전목차다음 →MetaContents

13장. REST API 와 FastAPI

실습 6 — 토큰과 역할로 권한 나누기

아이디와 비밀번호로 로그인하면 역할이 담긴 JWT 토큰을 주고, 엔드포인트마다 허락된 역할만 부를 수 있게 합니다. 사용자는 상담원, 분석가, 관리자 셋이고 이름과 비밀번호는 시험용으로 지어낸 것입니다. 서명 비밀 키는 환경 변수 DELAY_JWT_SECRET 에서 읽고, 없으면 시험용으로 그 자리에서 만듭니다. 실습 1을 먼저 실행해야 합니다.

1단계. 비밀번호를 해시로 보관하기

Python
import hashlib, hmac, os, secrets, time
from datetime import datetime, timedelta, timezone

def hash_password(password, salt=None):
    """소금(salt)을 붙여 20만 번 해시한다. 같은 비밀번호도 사용자마다 다른 해시가 된다"""
    salt = salt or secrets.token_bytes(16)
    return salt, hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 200_000)

# 시험용 사용자. 실제로는 사용자가 정한 비밀번호의 해시만 DB 에 있다
TEST_PASSWORDS = {"상담원1": secrets.token_urlsafe(9), "분석가1": secrets.token_urlsafe(9), "관리자1": secrets.token_urlsafe(9)}
USERS = {}
for name, role in [("상담원1", "상담원"), ("분석가1", "분석가"), ("관리자1", "관리자")]:
    salt, h = hash_password(TEST_PASSWORDS[name])
    USERS[name] = {"역할": role, "salt": salt, "hash": h}
t = time.perf_counter()
hash_password("시험", b"0" * 16)
print("사용자:", {n: u["역할"] for n, u in USERS.items()}, f"· 해시 한 번 {(time.perf_counter() - t) * 1000:.0f}ms")
출력
사용자: {'상담원1': '상담원', '분석가1': '분석가', '관리자1': '관리자'} · 해시 한 번 134ms

2단계. 로그인과 토큰 만들기

Python
import jwt
import joblib
import pandas as pd
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

SECRET = os.environ.get("DELAY_JWT_SECRET") or secrets.token_hex(32)      # 서명 비밀 키. 없으면 시험용으로 만든다
BUNDLE = joblib.load("out_delay_model.joblib")
app = FastAPI(title="배송 지연 예측 API(역할별 권한)")


class Login(BaseModel):
    아이디: str
    비밀번호: str


@app.post("/token")
def login(body: Login):
    user = USERS.get(body.아이디)
    ok = user is not None and hmac.compare_digest(hash_password(body.비밀번호, user["salt"])[1], user["hash"])
    if not ok:                                         # 아이디가 없는지 비밀번호가 틀렸는지 알려 주지 않는다
        raise HTTPException(status_code=401, detail="아이디나 비밀번호가 맞지 않습니다")
    exp = datetime.now(timezone.utc) + timedelta(minutes=30)               # 30분 뒤 만료
    token = jwt.encode({"sub": body.아이디, "role": user["역할"], "exp": exp}, SECRET, algorithm="HS256")
    return {"access_token": token, "token_type": "bearer", "역할": user["역할"]}


client = TestClient(app)
r = client.post("/token", json={"아이디": "분석가1", "비밀번호": TEST_PASSWORDS["분석가1"]})
token = r.json()["access_token"]
print(r.status_code, r.json()["역할"], token[:20] + "…")
print("토큰 속:", jwt.decode(token, SECRET, algorithms=["HS256"]))
print("틀린 비밀번호:", client.post("/token", json={"아이디": "분석가1", "비밀번호": "틀림"}).status_code)
출력
200 분석가 eyJhbGciOiJIUzI1NiIs…
토큰 속: {'sub': '분석가1', 'role': '분석가', 'exp': 1793163542}
틀린 비밀번호: 401

3단계. 역할을 확인하는 의존성과 엔드포인트

Python
bearer = HTTPBearer(auto_error=False)                 # Authorization: Bearer 토큰 머리글을 읽는다


def current_user(cred: HTTPAuthorizationCredentials | None = Depends(bearer)):
    """토큰이 없거나, 서명이 틀리거나, 만료됐으면 401"""
    if cred is None:
        raise HTTPException(status_code=401, detail="토큰이 없습니다")
    try:
        return jwt.decode(cred.credentials, SECRET, algorithms=["HS256"])
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="토큰이 만료됐습니다")
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="토큰이 올바르지 않습니다")


def allow(*roles):
    """허락할 역할을 받아, 그 역할이 아니면 403 을 내는 의존성을 만든다"""
    def check(user=Depends(current_user)):
        if user["role"] not in roles:
            raise HTTPException(status_code=403, detail=f"{user['role']} 은(는) 쓸 수 없습니다")
        return user
    return check


class Order(BaseModel):
    주문시각: int = Field(ge=0, le=23)
    거리km: float = Field(gt=0, le=1000)
    무게kg: float = Field(ge=0, le=100)
    지역: str
    상품분류: str
    날씨: str = "모름"
    택배사: str


def probs(orders):
    return BUNDLE["파이프라인"].predict_proba(pd.DataFrame([o.model_dump() for o in orders]))[:, 1].round(4).tolist()


@app.post("/predict")
def predict(order: Order, user=Depends(allow("상담원", "분석가", "관리자"))):
    return {"지연확률": probs([order])[0], "부른 사람": user["sub"]}


@app.post("/predict/batch")
def predict_batch(orders: list[Order], user=Depends(allow("분석가", "관리자"))):
    return {"건수": len(orders), "지연확률": probs(orders)}


@app.get("/model")
def model_info(user=Depends(allow("관리자"))):
    return {"판": BUNDLE["판"], "시험성능": BUNDLE["시험성능"]}


print([r.path for r in app.routes if r.path.startswith(("/predict", "/model", "/token"))])
출력
['/token', '/predict', '/predict/batch', '/model']

4단계. 역할마다 불러 보고 표로 보기

Python
order = {"주문시각": 22, "거리km": 197.6, "무게kg": 4.6, "지역": "지방", "상품분류": "도서", "날씨": "눈", "택배사": "빠른별택배"}
calls = [("POST", "/predict", order), ("POST", "/predict/batch", [order, order]), ("GET", "/model", None)]
rows = {}
for name in USERS:
    tok = client.post("/token", json={"아이디": name, "비밀번호": TEST_PASSWORDS[name]}).json()["access_token"]
    head = {"Authorization": f"Bearer {tok}"}
    rows[USERS[name]["역할"]] = [client.request(m, p, json=b, headers=head).status_code for m, p, b in calls]
# 토큰 없이, 위조한 토큰, 만료된 토큰
expired = jwt.encode({"sub": "관리자1", "role": "관리자", "exp": datetime.now(timezone.utc) - timedelta(minutes=1)}, SECRET, algorithm="HS256")
forged = jwt.encode({"sub": "누군가", "role": "관리자", "exp": datetime.now(timezone.utc) + timedelta(minutes=5)}, secrets.token_hex(32), algorithm="HS256")
for name, head in [("토큰 없음", {}), ("위조 토큰", {"Authorization": f"Bearer {forged}"}), ("만료 토큰", {"Authorization": f"Bearer {expired}"})]:
    rows[name] = [client.request(m, p, json=b, headers=head).status_code for m, p, b in calls]
print(pd.DataFrame(rows, index=[f"{m} {p}" for m, p, _ in calls]).T.to_string())
출력
       POST /predict  POST /predict/batch  GET /model
상담원              200                  403         403
분석가              200                  200         403
관리자              200                  200         200
토큰 없음            401                  401         401
위조 토큰            401                  401         401
만료 토큰            401                  401         401

이 실습이 뜻하는 것

이 실습은 회사 접수대에서 출입증을 받고, 층마다 직급에 따라 들어갈 수 있는 곳이 다른 것과 같습니다. 비밀번호는 금고에 원문 대신 소금을 친 요약값으로 보관합니다(1단계). 접수대에서 아이디와 비밀번호를 확인하면 이름, 직급, 유효 시간을 적고 도장을 찍은 출입증을 줍니다(2단계). 층마다 문에 "출입증 확인"과 "이 직급만"을 붙이고(3단계), 직급마다, 그리고 출입증 없이, 위조한 출입증으로, 기한이 지난 출입증으로 층마다 들어가 봅니다(4단계). 여기서 출입증이 JWT(JSON Web Token) 토큰, 도장이 서명 비밀 키, 직급이 역할, 층이 엔드포인트입니다. 출입증처럼 토큰에 적힌 글은 누구나 읽을 수 있고, 도장은 고쳐 쓰지 못하게 막을 뿐 내용을 가리지 않습니다.

단계출력뜻
1. 해시 보관사용자: {'상담원1': '상담원', …} · 해시 한 번 134ms시험용 사용자 셋을 만들었습니다. 비밀번호 확인 한 번에 0.134초가 걸리게 일부러 느리게 해, 비밀번호를 하나씩 대입해 보는 공격을 어렵게 합니다
2. 로그인200 분석가 eyJhbGciOiJIUzI1NiIs…분석가1 이 로그인해 토큰을 받았습니다
2. 토큰 속{'sub': '분석가1', 'role': '분석가', 'exp': 1793163542}토큰 안에 누구인지, 역할, 만료 시각(숫자로 적은 시각)이 들어 있습니다. 그래서 비밀번호 같은 비밀은 넣지 않습니다
2. 틀린 비밀번호틀린 비밀번호: 401틀리면 토큰을 주지 않습니다
3. 경로['/token', '/predict', '/predict/batch', '/model']로그인 하나와, 허락하는 역할이 서로 다른 엔드포인트 셋이 만들어졌습니다
4. 역할마다상담원 200·403·403, 분석가 200·200·403, 관리자 200·200·200403 은 "누구인지는 알지만 이 일은 허락되지 않음"입니다. 역할이 높을수록 열리는 문이 늘어납니다
4. 막힌 토큰토큰 없음, 위조 토큰, 만료 토큰 모두 401출입증이 없거나, 다른 키로 도장을 찍었거나, 기한이 지나면 어느 문이든 401 입니다

한 줄로 줄이면, 401 은 "누구인지 모름", 403 은 "누구인지 알지만 허락 안 됨"으로 나뉘고, 역할마다 쓸 수 있는 엔드포인트가 본문 "권한" 절의 표대로 갈렸다는 실습입니다. 프로젝트 1에서는 이 생각을 사내 규정 질의 API 의 키, 관리자만 보는 통계, 횟수 제한에 씁니다. 사용자와 비밀번호는 시험용으로 만든 것이고, 134ms 는 이 PC 에서 잰 값입니다.

코드 설명

  • hashlib.pbkdf2_hmac 은 비밀번호에 소금을 붙여 20만 번 해시합니다. 이 PC 에서 한 번에 134ms 가 걸렸는데, 일부러 느리게 해서 비밀번호를 하나씩 대입해 보는 공격을 어렵게 합니다. 시험용 비밀번호도 secrets 로 그 자리에서 만들어 코드에 적지 않았습니다.
  • jwt.encode() 는 사용자(sub), 역할(role), 만료 시각(exp)을 서명한 토큰을 만듭니다. 토큰 속은 누구나 읽을 수 있으므로(서명은 바꾸지 못하게 할 뿐 가리지 않는다) 비밀번호 같은 것은 넣지 않습니다. 틀린 비밀번호는 401 이고, 아이디가 없는지 비밀번호가 틀렸는지는 알려 주지 않습니다.
  • current_user() 는 서명과 만료를 확인해 401 을 가르고, allow(*roles) 는 그 위에서 역할을 확인해 403 을 가릅니다. 엔드포인트마다 Depends(allow("분석가", "관리자")) 처럼 허락할 역할만 적으면 됩니다.
  • 4단계의 표가 본문 "권한" 절의 표와 같게 나왔습니다. 상담원은 묶음 예측과 모델 정보에서 403, 분석가는 모델 정보에서 403, 관리자는 모두 200 입니다. 토큰 없음, 다른 비밀 키로 서명한 위조 토큰, 만료된 토큰은 엔드포인트와 상관없이 모두 401 입니다.
← 이전 절13장 · 실습 5 — API 키로 접근 막기다음 절 →13장 · 프로젝트 1 — 사내 규정 질의 API