본문 바로가기

인증 구현하기

1. 인증 구현

앞서 배운 JWT를 사용하여 사용자 인증을 구현해보겠습니다. 사용자는 회원가입을 통해 계정을 생성하고, 로그인을 통해 토큰을 발급받습니다. 이 토큰을 통해 사용자는 인증된 요청을 보낼 수 있습니다.

이러한 인증을 구현해놓으면 기존에 구현된 API도 수정을 해야 합니다. 예를 들어, '수정'의 경우에는 로그인 한 사용자 중에서도 자신이 작성한 글만 수정할 수 있어야 합니다. '삭제'도 마찬가지입니다. 작성자도 지금처럼 admin으로 고정할 수 없고, 로그인한 사용자로 채워야 합니다. 저장 방식 또한 고민해볼 필요가 있습니다. 여러분이 숨긴 게시물을 보거나, 채팅 기록을 보거나, 비밀번호를 확인할 수 있어서도 안될 것입니다.

권한을 표로 정리하면 아래와 같습니다.

기능비로그인로그인 (남의 글)로그인 (내 글)
목록 조회가능가능가능
상세 조회가능가능가능
글 작성401가능가능
글 수정401403가능
글 삭제401403가능

401과 403은 뜻이 다릅니다.

  • 401 Unauthorized: 누구인지 모르겠습니다. 로그인이 필요합니다.
  • 403 Forbidden: 누구인지는 알겠는데, 그것을 할 권한이 없습니다.

두 코드를 구분해서 쓰면 프론트엔드가 다르게 대응할 수 있습니다. 401이면 로그인 페이지로 보내고, 403이면 "권한이 없습니다"라고 안내하면 됩니다.

이런 요구사항을 모두 구현한 실제 운영되는 블로그 서비스를 만들기 위해서는 많은 고민과 노력이 필요합니다. 비밀번호 재설정, 이메일 인증, 계정 잠금, 관리자 권한, 탈퇴 처리가 모두 필요하고, 잘못 만들면 그대로 보안 사고가 됩니다. 또한 합당한 라이브러리를 사용하고 있는지 냉정하게 살펴볼 필요가 있습니다. 이러한 서비스를 만들기 위해 필요한 기술은 Django와 같은 프레임워크에는 이미 구현되어 있기 때문입니다. 많이 하는 실수 중 하나가 '배운 기술이'라서 기술을 선택하는 것입니다.

이 절에서는 간단한 블로그에 인증을 구현해보겠습니다. 우선은 코드를 조각내어 각각의 기능을 확인하고, 마지막에 하나로 합쳐보겠습니다.

1.1 필요한 모듈 확인

JWT 토큰 생성과 검증을 위한 PyJWT와 비밀번호 해싱을 위한 pwdlib은 05-1절에서 설치하였습니다. 이 절로 바로 들어왔다면 아래 명령어로 설치해주세요.

pip install "fastapi[standard]" sqlalchemy pyjwt "pwdlib[argon2]"
  • PyJWT: JWT 토큰 생성 및 검증
  • pwdlib[argon2]: 비밀번호 해싱

1.2 기존 데이터베이스 파일 지우기

블로그 테이블에 작성자 컬럼이 추가되므로 기존 파일과 구조가 맞지 않습니다. 4장에서 설명했듯이 create_all은 이미 있는 테이블을 고치지 않습니다.

서버를 끄고 blogs.db 파일을 지워주세요. 지금까지 만든 글도 함께 사라집니다.

1.3 임포트와 설정

main.py를 아래 코드로 바꿉니다. 앞 절의 코드에 인증 관련 부분이 추가된 형태입니다.

from collections.abc import Generator
from contextlib import asynccontextmanager
from datetime import date, datetime, timedelta, timezone
from typing import Annotated

import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pwdlib import PasswordHash
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from sqlalchemy import ForeignKey, create_engine, select
from sqlalchemy.orm import (
    DeclarativeBase,
    Mapped,
    Session,
    mapped_column,
    relationship,
    sessionmaker,
)

# ------------------------------------------------------------------
# 설정
# ------------------------------------------------------------------
SQLALCHEMY_DATABASE_URL = "sqlite:///./blogs.db"

# 실제 서비스에서는 환경 변수로 관리해야 합니다(7장에서 다룹니다).
SECRET_KEY = "9f2c8e1b47a5d3f6089b2e7c4a1d5f83b6e0c9a2d7f4b1e8c3a6d9f2b5e8c1a4"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60

engine = create_engine(
    SQLALCHEMY_DATABASE_URL,
    connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(bind=engine)

password_hash = PasswordHash.recommended()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")


class Base(DeclarativeBase):
    pass

1.4 데이터베이스 모델 수정

사용자 정보를 저장할 User 모델을 추가하고, 블로그 테이블에 작성자를 연결합니다.

class UserModel(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    email: Mapped[str] = mapped_column(unique=True, index=True)
    hashed_password: Mapped[str]
    created_at: Mapped[date]

    blogs: Mapped[list["BlogModel"]] = relationship(back_populates="author")


class BlogModel(Base):
    __tablename__ = "blogs"

    id: Mapped[int] = mapped_column(primary_key=True, index=True)
    title: Mapped[str] = mapped_column(index=True)
    content: Mapped[str]
    author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
    created_at: Mapped[date]
    updated_at: Mapped[date]

    author: Mapped["UserModel"] = relationship(back_populates="blogs")

    @property
    def author_email(self) -> str:
        return self.author.email

새로 나온 것이 두 가지입니다.

ForeignKey("users.id"): "이 컬럼의 값은 users 테이블의 id 중 하나여야 한다"는 제약입니다. 없는 사용자의 글이 만들어지는 것을 데이터베이스 차원에서 막아줍니다.

relationship: 파이썬 코드에서 두 모델을 오갈 수 있게 해줍니다. blog.author로 작성자 객체를, user.blogs로 그 사용자의 글 목록을 가져올 수 있습니다. back_populates는 반대편 속성의 이름을 알려주는 것입니다.

author_email 프로퍼티는 응답에 작성자 이메일을 담기 위해 만들었습니다. blog.author.email을 매번 쓰지 않아도 되고, 잠시 뒤 Pydantic이 이 값을 자동으로 읽어갑니다.

1.5 Pydantic 스키마

class UserCreate(BaseModel):
    email: EmailStr
    password: str = Field(min_length=8)


class UserPublic(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    email: EmailStr
    created_at: date


class Token(BaseModel):
    access_token: str
    token_type: str = "bearer"


class BlogCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    content: str = Field(min_length=1)


class BlogUpdate(BlogCreate):
    pass


class Blog(BlogCreate):
    model_config = ConfigDict(from_attributes=True)

    id: int
    author_id: int
    author_email: EmailStr
    created_at: date
    updated_at: date

EmailStr은 이메일 형식을 검증하는 Pydantic 타입입니다. fastapi[standard]에 포함된 email-validator 덕분에 추가 설치 없이 쓸 수 있습니다. hong@example처럼 형식이 어긋나면 422로 거부됩니다.

Blog에 author_email 필드가 있고, 모델 쪽에 같은 이름의 프로퍼티가 있습니다. from_attributes=True가 켜져 있으므로 Pydantic이 그 프로퍼티를 읽어 값을 채웁니다.

UserPublic에 hashed_password가 없다는 점을 확인하세요. 2장에서 배운 대로, 응답 모델이 민감한 값을 걸러냅니다.

1.6 인증 관련 유틸리티 함수

비밀번호 해싱과 JWT 토큰 생성, 검증을 위한 함수들을 구현합니다.

def get_db() -> Generator[Session, None, None]:
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


SessionDep = Annotated[Session, Depends(get_db)]


def create_access_token(user_id: int) -> str:
    expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    payload = {"sub": str(user_id), "exp": expire}
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)


def get_current_user(
    token: Annotated[str, Depends(oauth2_scheme)],
    db: SessionDep,
) -> UserModel:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="자격 증명을 확인할 수 없습니다",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except jwt.InvalidTokenError:
        raise credentials_exception

    user_id = payload.get("sub")
    if user_id is None:
        raise credentials_exception

    user = db.get(UserModel, int(user_id))
    if user is None:
        raise credentials_exception

    return user


CurrentUser = Annotated[UserModel, Depends(get_current_user)]

토큰의 sub에 이메일이 아니라 사용자 ID를 넣었습니다. 이메일은 나중에 사용자가 바꿀 수 있는 값이기 때문입니다. 바뀔 수 있는 값을 식별자로 쓰면, 이메일을 바꾼 순간 기존 토큰이 가리키는 사람이 없어집니다.

sub에 str(user_id)처럼 문자열로 넣은 것도 이유가 있습니다. JWT 표준이 sub를 문자열로 정의하고 있어, 숫자를 그대로 넣으면 검증 단계에서 문제가 생길 수 있습니다.

1.7 시작할 때 할 일

@asynccontextmanager
async def lifespan(app: FastAPI):
    Base.metadata.create_all(bind=engine)
    yield


app = FastAPI(title="위니브 블로그 API", lifespan=lifespan)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=False,
    allow_methods=["*"],
    allow_headers=["*"],
)

예시 데이터를 넣던 seed_data는 뺐습니다. 이제 글에는 작성자가 필요한데, 사용자가 없는 상태에서는 예시 글을 만들 수 없기 때문입니다. 회원가입을 하고 직접 글을 써보시면 됩니다.

1.8 FastAPI 엔드포인트 구현

회원가입과 로그인을 위한 엔드포인트를 구현합니다.

@app.post("/signup", status_code=status.HTTP_201_CREATED, tags=["인증"])
def signup(user_data: UserCreate, db: SessionDep) -> UserPublic:
    """회원가입을 합니다. 비밀번호는 해시로 변환되어 저장됩니다."""
    existing = db.scalar(select(UserModel).where(UserModel.email == user_data.email))
    if existing is not None:
        raise HTTPException(status_code=400, detail="이미 가입된 이메일입니다")

    user = UserModel(
        email=user_data.email,
        hashed_password=password_hash.hash(user_data.password),
        created_at=date.today(),
    )
    db.add(user)
    db.commit()
    db.refresh(user)
    return user


@app.post("/token", tags=["인증"])
def login(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
    db: SessionDep,
) -> Token:
    """이메일과 비밀번호를 확인하고 액세스 토큰을 발급합니다."""
    user = db.scalar(select(UserModel).where(UserModel.email == form_data.username))
    if user is None or not password_hash.verify(
        form_data.password, user.hashed_password
    ):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="이메일 또는 비밀번호가 올바르지 않습니다",
            headers={"WWW-Authenticate": "Bearer"},
        )

    return Token(access_token=create_access_token(user.id))


@app.get("/me", tags=["인증"])
def read_me(current_user: CurrentUser) -> UserPublic:
    """지금 로그인한 사용자를 반환합니다."""
    return current_user

로그인 폼의 필드 이름이 username인데 이메일을 받고 있습니다. 4장에서 설명했듯이 OAuth2 표준이 정한 이름이라 그대로 씁니다.

에러 메시지에 담을 정보의 범위

로그인 실패 시 "이메일이 없습니다"와 "비밀번호가 틀립니다"를 구분해서 알려주면 사용자는 편합니다. 그런데 이렇게 하면 공격자가 어떤 이메일이 가입되어 있는지 하나씩 확인할 수 있게 됩니다.

위 코드는 두 경우를 하나의 메시지로 합쳤습니다. 회원가입 쪽은 "이미 가입된 이메일입니다"라고 알려주고 있는데, 이것도 같은 정보를 흘립니다. 실제 서비스에서는 "입력하신 주소로 안내 메일을 보냈습니다"처럼 응답하고 실제 처리는 메일로 넘기는 방식을 쓰기도 합니다.

편의와 보안 사이에서 어디에 선을 그을지는 서비스마다 다릅니다. 중요한 것은 이런 선택지가 있다는 것을 알고 정하는 것입니다.

1.9 블로그 API에 인증 적용

기존 블로그 API에 인증을 적용합니다.

def get_blog_or_404(db: Session, blog_id: int) -> BlogModel:
    blog = db.get(BlogModel, blog_id)
    if blog is None:
        raise HTTPException(status_code=404, detail="Blog not found")
    return blog


def check_owner(blog: BlogModel, user: UserModel) -> None:
    """본인 글이 아니면 403을 발생시킵니다."""
    if blog.author_id != user.id:
        raise HTTPException(status_code=403, detail="본인이 작성한 글만 수정하거나 삭제할 수 있습니다")


@app.get("/blogs", tags=["블로그"])
def read_blogs(db: SessionDep) -> list[Blog]:
    """모든 글을 최신순으로 반환합니다. 로그인 없이 볼 수 있습니다."""
    return list(db.scalars(select(BlogModel).order_by(BlogModel.id.desc())).all())


@app.get("/blogs/{blog_id}", tags=["블로그"])
def read_blog(blog_id: int, db: SessionDep) -> Blog:
    """글 하나를 반환합니다. 로그인 없이 볼 수 있습니다."""
    return get_blog_or_404(db, blog_id)


@app.post("/blogs", status_code=status.HTTP_201_CREATED, tags=["블로그"])
def create_blog(blog_data: BlogCreate, current_user: CurrentUser, db: SessionDep) -> Blog:
    """새 글을 작성합니다. 로그인이 필요합니다."""
    today = date.today()
    blog = BlogModel(
        title=blog_data.title,
        content=blog_data.content,
        author_id=current_user.id,
        created_at=today,
        updated_at=today,
    )
    db.add(blog)
    db.commit()
    db.refresh(blog)
    return blog


@app.put("/blogs/{blog_id}", tags=["블로그"])
def update_blog(
    blog_id: int,
    blog_data: BlogUpdate,
    current_user: CurrentUser,
    db: SessionDep,
) -> Blog:
    """글을 수정합니다. 본인이 작성한 글만 가능합니다."""
    blog = get_blog_or_404(db, blog_id)
    check_owner(blog, current_user)

    blog.title = blog_data.title
    blog.content = blog_data.content
    blog.updated_at = date.today()
    db.commit()
    db.refresh(blog)
    return blog


@app.delete("/blogs/{blog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["블로그"])
def delete_blog(blog_id: int, current_user: CurrentUser, db: SessionDep) -> None:
    """글을 삭제합니다. 본인이 작성한 글만 가능합니다."""
    blog = get_blog_or_404(db, blog_id)
    check_owner(blog, current_user)

    db.delete(blog)
    db.commit()


app.frontend("/", directory="static")

current_user: CurrentUser 한 줄이 붙는 것만으로 그 엔드포인트는 로그인이 필요해집니다. 목록과 상세 조회에는 붙이지 않았으므로 누구나 볼 수 있습니다.

check_owner 함수를 따로 만든 이유는 수정과 삭제 두 곳에서 같은 검사를 하기 때문입니다. 이런 검사가 한 줄씩 흩어져 있으면, 나중에 엔드포인트를 추가할 때 빠뜨리기 쉽습니다.

404를 먼저 확인하고 403을 확인하는 순서

get_blog_or_404를 먼저 호출하고 check_owner를 나중에 호출했습니다. 순서를 바꾸면 없는 글에 대해 403이 나가게 되어 이상합니다.

다만 보안을 더 엄격하게 보는 관점에서는, 남의 글에 접근했을 때 403 대신 404를 반환하기도 합니다. 403을 반환하면 "그 ID의 글이 존재한다"는 사실을 알려주기 때문입니다. 이 책에서는 학습 목적상 403을 명확히 구분해서 보여주고 있습니다.

2. 백엔드 먼저 테스트하기

화면을 고치기 전에 API가 제대로 동작하는지 확인합니다. api.http 파일을 아래와 같이 작성합니다.

@baseUrl = http://127.0.0.1:8000

### 1. 첫 번째 사용자 회원가입
POST {{baseUrl}}/signup
Content-Type: application/json

{
    "email": "licat@weniv.co.kr",
    "password": "test1234"
}

### 2. 두 번째 사용자 회원가입
POST {{baseUrl}}/signup
Content-Type: application/json

{
    "email": "mura@weniv.co.kr",
    "password": "test1234"
}

### 3. 첫 번째 사용자로 로그인
# @name login1
POST {{baseUrl}}/token
Content-Type: application/x-www-form-urlencoded

username=licat@weniv.co.kr&password=test1234

### 4. 두 번째 사용자로 로그인
# @name login2
POST {{baseUrl}}/token
Content-Type: application/x-www-form-urlencoded

username=mura@weniv.co.kr&password=test1234

### 5. 내 정보 확인
GET {{baseUrl}}/me
Authorization: Bearer {{login1.response.body.access_token}}

### 6. 첫 번째 사용자가 글 작성
POST {{baseUrl}}/blogs
Content-Type: application/json
Authorization: Bearer {{login1.response.body.access_token}}

{
    "title": "licat의 글",
    "content": "안녕하세요"
}

### 7. 목록 조회 (로그인 없이도 됩니다)
GET {{baseUrl}}/blogs

### 8. 로그인 없이 글 작성 (401이 나와야 정상입니다)
POST {{baseUrl}}/blogs
Content-Type: application/json

{
    "title": "몰래 쓰기",
    "content": "안 될 겁니다"
}

### 9. 두 번째 사용자가 남의 글 수정 시도 (403이 나와야 정상입니다)
PUT {{baseUrl}}/blogs/1
Content-Type: application/json
Authorization: Bearer {{login2.response.body.access_token}}

{
    "title": "남의 글 고치기",
    "content": "안 될 겁니다"
}

### 10. 본인 글 수정 (200이 나와야 정상입니다)
PUT {{baseUrl}}/blogs/1
Content-Type: application/json
Authorization: Bearer {{login1.response.body.access_token}}

{
    "title": "고친 제목",
    "content": "고친 내용"
}

### 11. 두 번째 사용자가 남의 글 삭제 시도 (403이 나와야 정상입니다)
DELETE {{baseUrl}}/blogs/1
Authorization: Bearer {{login2.response.body.access_token}}

순서대로 실행하면서 8번은 401, 9번과 11번은 403이 나오는지 확인해보세요. 이 세 가지가 이번 절에서 만든 것의 핵심입니다.

7번 응답에 author_email이 담겨 있는 것도 확인해보세요. relationship과 프로퍼티가 동작한 결과입니다.

3. 프론트엔드 수정

인증 기능을 사용하기 위해 프론트엔드 코드도 수정해야 합니다. 로컬 스토리지에 토큰을 저장하고, API 요청 시 이 토큰을 함께 전송합니다.

3.1 공통 함수 만들기

static/common.js를 아래와 같이 바꿉니다. 여러 페이지에서 쓸 함수들을 모아둡니다.

// 화면과 API가 같은 서버에서 제공되므로 주소를 비워둡니다.
const API = "";

// 저장된 토큰을 꺼냅니다. 없으면 null입니다.
function getToken() {
    return localStorage.getItem("token");
}

// 로그인이 필요한 요청에 붙일 헤더를 만듭니다.
function authHeaders() {
    return {
        "Content-Type": "application/json",
        Authorization: `Bearer ${getToken()}`,
    };
}

// 로그인하지 않았다면 로그인 페이지로 보냅니다.
function requireLogin() {
    if (!getToken()) {
        alert("로그인이 필요합니다.");
        location.href = "login.html";
        return false;
    }
    return true;
}

// 로그아웃합니다.
function logout() {
    localStorage.removeItem("token");
    location.href = "blog_list.html";
}

// 401이나 403 응답을 공통으로 처리합니다.
function handleAuthError(response) {
    if (response.status === 401) {
        alert("로그인이 만료되었습니다. 다시 로그인해주세요.");
        localStorage.removeItem("token");
        location.href = "login.html";
        return true;
    }
    if (response.status === 403) {
        alert("본인이 작성한 글만 수정하거나 삭제할 수 있습니다.");
        return true;
    }
    return false;
}

handleAuthError처럼 공통 처리를 한 곳에 모아두면, 페이지가 늘어나도 같은 코드를 반복하지 않아도 됩니다.

3.2 로그인 페이지

static/login.html 파일을 생성하고 다음과 같이 작성합니다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>로그인</title>
</head>
<body>
    <h1>로그인</h1>
    <form>
        <input type="email" name="email" placeholder="이메일" required><br>
        <input type="password" name="password" placeholder="비밀번호" required><br>
        <button type="submit">로그인</button>
    </form>
    <a href="register.html">회원가입</a>

    <script src="common.js"></script>
    <script>
        const form = document.querySelector("form");

        form.addEventListener("submit", async (event) => {
            event.preventDefault();

            // /token은 JSON이 아니라 폼 데이터를 받습니다.
            // 필드 이름도 email이 아니라 username이어야 합니다.
            const formData = new URLSearchParams();
            formData.append("username", event.target.email.value);
            formData.append("password", event.target.password.value);

            const response = await fetch(`${API}/token`, {
                method: "POST",
                headers: { "Content-Type": "application/x-www-form-urlencoded" },
                body: formData,
            });

            if (!response.ok) {
                alert("이메일 또는 비밀번호가 올바르지 않습니다.");
                return;
            }

            const data = await response.json();
            localStorage.setItem("token", data.access_token);
            location.href = "blog_list.html";
        });
    </script>
</body>
</html>

URLSearchParams를 사용해 폼 형식으로 보내는 부분이 다른 요청과 다릅니다. FormData를 써도 되지만, 그 경우 Content-Type 헤더를 직접 적으면 안 되므로 처음에는 위 방식이 덜 헷갈립니다.

3.3 회원가입 페이지

static/register.html 파일을 생성하고 다음과 같이 작성합니다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>회원가입</title>
</head>
<body>
    <h1>회원가입</h1>
    <form>
        <input type="email" name="email" placeholder="이메일" required><br>
        <input type="password" name="password" placeholder="비밀번호 (8자 이상)" required minlength="8"><br>
        <button type="submit">회원가입</button>
    </form>
    <a href="login.html">이미 계정이 있으신가요</a>

    <script src="common.js"></script>
    <script>
        const form = document.querySelector("form");

        form.addEventListener("submit", async (event) => {
            event.preventDefault();

            const response = await fetch(`${API}/signup`, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({
                    email: event.target.email.value,
                    password: event.target.password.value,
                }),
            });

            if (!response.ok) {
                const data = await response.json();
                alert(`회원가입 실패: ${JSON.stringify(data.detail)}`);
                return;
            }

            alert("회원가입이 완료되었습니다. 로그인해주세요.");
            location.href = "login.html";
        });
    </script>
</body>
</html>

3.4 목록 페이지에 로그인 상태 표시

static/blog_list.html의 <body> 부분과 스크립트를 아래와 같이 수정합니다.

<body>
    <h1>블로그 목록</h1>
    <nav>
        <a href="blog_create.html">새 글 쓰기</a>
        <span class="auth-area"></span>
    </nav>
    <main></main>

    <script src="common.js"></script>
    <script>
        const main = document.querySelector("main");
        const authArea = document.querySelector(".auth-area");

        // 로그인 상태에 따라 다른 링크를 보여줍니다
        async function renderAuthArea() {
            if (!getToken()) {
                authArea.innerHTML = `
                    | <a href="login.html">로그인</a>
                    | <a href="register.html">회원가입</a>
                `;
                return;
            }

            const response = await fetch(`${API}/me`, { headers: authHeaders() });
            if (!response.ok) {
                localStorage.removeItem("token");
                authArea.innerHTML = `| <a href="login.html">로그인</a>`;
                return;
            }

            const user = await response.json();
            authArea.innerHTML = `
                | ${user.email}
                | <button onclick="logout()">로그아웃</button>
            `;
        }

        async function loadBlogs() {
            const response = await fetch(`${API}/blogs`);
            if (!response.ok) {
                main.innerHTML = "<p>목록을 불러오지 못했습니다.</p>";
                return;
            }

            const data = await response.json();
            if (data.length === 0) {
                main.innerHTML = "<p>아직 글이 없습니다.</p>";
                return;
            }

            main.innerHTML = "";
            data.forEach((blog) => {
                const article = document.createElement("article");
                article.innerHTML = `
                    <h2><a href="blog_detail.html?id=${blog.id}">${blog.title}</a></h2>
                    <p>${blog.author_email} | ${blog.created_at}</p>
                    <hr>
                `;
                main.appendChild(article);
            });
        }

        renderAuthArea();
        loadBlogs();
    </script>
</body>

/me를 호출해서 로그인 상태를 확인하는 부분이 핵심입니다. 로컬 스토리지에 토큰이 있다고 해서 그 토큰이 유효한 것은 아닙니다. 만료되었을 수도 있으므로 서버에 물어봐야 합니다.

3.5 상세 페이지에서 본인 글일 때만 버튼 보여주기

static/blog_detail.html의 스크립트를 아래와 같이 수정합니다.

<body>
    <h1>블로그 상세</h1>
    <main></main>
    <a href="blog_list.html">목록으로 가기</a>
    <span class="owner-buttons"></span>

    <script src="common.js"></script>
    <script>
        const main = document.querySelector("main");
        const ownerButtons = document.querySelector(".owner-buttons");
        const urlParams = new URLSearchParams(location.search);
        const id = urlParams.get("id");

        async function loadBlog() {
            const response = await fetch(`${API}/blogs/${id}`);
            if (!response.ok) {
                main.innerHTML = "<p>해당 글을 찾을 수 없습니다.</p>";
                return;
            }

            const blog = await response.json();
            main.innerHTML = `
                <h2>${blog.title}</h2>
                <p>${blog.author_email} | 작성 ${blog.created_at} | 수정 ${blog.updated_at}</p>
                <p>${blog.content}</p>
            `;

            await renderOwnerButtons(blog);
        }

        // 내가 쓴 글일 때만 수정과 삭제 버튼을 보여줍니다
        async function renderOwnerButtons(blog) {
            if (!getToken()) {
                return;
            }

            const response = await fetch(`${API}/me`, { headers: authHeaders() });
            if (!response.ok) {
                return;
            }

            const user = await response.json();
            if (user.id !== blog.author_id) {
                return;
            }

            ownerButtons.innerHTML = `
                <button class="edit">수정하기</button>
                <button class="delete">삭제하기</button>
            `;

            document.querySelector(".edit").addEventListener("click", () => {
                location.href = `blog_edit.html?id=${id}`;
            });

            document.querySelector(".delete").addEventListener("click", async () => {
                if (!confirm("정말 삭제하시겠습니까?")) {
                    return;
                }

                const res = await fetch(`${API}/blogs/${id}`, {
                    method: "DELETE",
                    headers: authHeaders(),
                });

                if (handleAuthError(res)) {
                    return;
                }
                if (!res.ok) {
                    alert("삭제에 실패했습니다.");
                    return;
                }
                location.href = "blog_list.html";
            });
        }

        loadBlog();
    </script>
</body>

화면에서 버튼을 감추는 것은 보안이 아닙니다

남의 글에서 버튼이 보이지 않는 것은 사용자 편의를 위한 것입니다. 개발자 도구를 열어 버튼을 강제로 만들어내거나, .http 파일로 직접 요청을 보내면 버튼 없이도 요청은 갈 수 있습니다.

실제로 막는 것은 서버의 check_owner 함수입니다. 화면 처리와 서버 처리는 목적이 다릅니다. 화면은 편의, 서버는 보안입니다. 서버 검사를 빼놓고 화면만 감추는 것은 아무것도 막지 못합니다.

앞서 .http 파일 9번과 11번으로 403이 나오는 것을 확인한 이유가 여기에 있습니다.

3.6 작성과 수정 페이지에 토큰 붙이기

이제 인증이 필요한 API 요청에는 토큰을 포함시켜야 합니다. static/blog_create.html의 스크립트를 아래와 같이 수정합니다.

    <script src="common.js"></script>
    <script>
        // 로그인하지 않았으면 바로 로그인 페이지로 보냅니다
        requireLogin();

        const form = document.querySelector("form");

        form.addEventListener("submit", async (event) => {
            event.preventDefault();

            const response = await fetch(`${API}/blogs`, {
                method: "POST",
                headers: authHeaders(),  // 토큰이 담긴 헤더를 씁니다
                body: JSON.stringify({
                    title: event.target.title.value,
                    content: event.target.content.value,
                }),
            });

            if (handleAuthError(response)) {
                return;
            }
            if (!response.ok) {
                alert("작성에 실패했습니다.");
                return;
            }

            const blog = await response.json();
            location.href = `blog_detail.html?id=${blog.id}`;
        });
    </script>

static/blog_edit.html도 같은 방식으로 수정합니다.

    <script src="common.js"></script>
    <script>
        requireLogin();

        const form = document.querySelector("form");
        const urlParams = new URLSearchParams(location.search);
        const id = urlParams.get("id");

        async function loadBlog() {
            const response = await fetch(`${API}/blogs/${id}`);
            if (!response.ok) {
                alert("해당 글을 찾을 수 없습니다.");
                location.href = "blog_list.html";
                return;
            }

            const blog = await response.json();
            form.title.value = blog.title;
            form.content.value = blog.content;
        }

        form.addEventListener("submit", async (event) => {
            event.preventDefault();

            const response = await fetch(`${API}/blogs/${id}`, {
                method: "PUT",
                headers: authHeaders(),
                body: JSON.stringify({
                    title: event.target.title.value,
                    content: event.target.content.value,
                }),
            });

            if (handleAuthError(response)) {
                return;
            }
            if (!response.ok) {
                alert("수정에 실패했습니다.");
                return;
            }

            location.href = `blog_detail.html?id=${id}`;
        });

        loadBlog();
    </script>

기존 코드에서 바뀐 부분은 두 곳뿐입니다. headers를 authHeaders()로 바꾼 것과, handleAuthError를 추가한 것입니다. 이렇게 하면 인증된 사용자만 블로그 글을 작성, 수정, 삭제할 수 있게 됩니다. 또한 각 사용자는 자신이 작성한 글만 수정하고 삭제할 수 있습니다.

4. 전체 흐름 확인하기

서버를 실행하고 http://127.0.0.1:8000/에 접속합니다.

fastapi dev

아래 순서로 확인해보세요.

  1. 로그인하지 않은 상태에서 목록이 보입니다.
  2. 새 글 쓰기를 누르면 로그인 페이지로 보내집니다.
  3. 회원가입을 하고 로그인합니다.
  4. 목록 위에 내 이메일과 로그아웃 버튼이 보입니다.
  5. 글을 하나 씁니다. 작성자가 내 이메일로 표시됩니다.
  6. 상세 페이지에 수정과 삭제 버튼이 보입니다.
  7. 로그아웃하고 그 글에 다시 들어가면 버튼이 보이지 않습니다.
  8. 다른 브라우저(또는 시크릿 창)에서 두 번째 계정을 만들고 로그인합니다.
  9. 첫 번째 계정의 글에 들어가면 수정과 삭제 버튼이 보이지 않습니다.

9번까지 확인되면 이번 절의 목표를 달성한 것입니다.

5. 전체 소스코드

전체 소스코드는 아래 GitHub에서 확인하실 수 있습니다.

FastAPI 베이스캠프 소스코드

연습문제

  1. 내가 쓴 글만 모아 보는 GET /me/blogs 엔드포인트를 만들어보세요. current_user.blogs를 사용하면 됩니다.
  2. 목록 페이지에서 내 글에만 (내 글) 표시를 붙여보세요.
  3. 토큰 만료 시간을 1분으로 줄이고, 만료된 뒤 글을 쓰려고 하면 어떻게 되는지 확인해보세요.
  4. 회원가입할 때 비밀번호를 두 번 입력받아 일치하는지 확인하는 기능을 화면에 추가해보세요.
  5. 관리자 개념을 추가해보세요. UserModel에 is_admin 컬럼을 넣고, 관리자는 모든 글을 수정하고 삭제할 수 있도록 check_owner를 고쳐보세요.