Programming/Project Log

[가계부 만들기] Backend - 회원 관리 기능 #3

minarae7 2023. 4. 5. 23:28
728x90
반응형

FastAPI Logo

기능 개발을 해가다보니 앞에 개발했던 부분에서 뭔가 부족한 부분을 발견하고 추가하게 된다.

앞선 회원 관리 기능 포스팅에서 schema에 대한 내용을 정리했었는데 회원 정보에 대한 관리 기능을 구현하다가 보니 처음에는 생각하지 못했던 부분을 추가하게 되었다.

Schema

현재 버전의 schema.py 파일은 아래 내용과 같다.

from pydantic import BaseModel, Field
from typing import Optional

# 회원 가입에 대한 Request Schema
class MemberCreate(BaseModel):
    member_id: str = Field(title="사용자 아이디", max_length=30)
    member_pw: str = Field(title="사용자 패스워드")
    member_name: str = Field(title="사용자 이름", max_length=20)
    member_email: str = Field(title="사용자 이메일", max_length=50)

    class Config:
        orm_mode = True

# 회원 정보 수정에 대한 Request Schema
class MemberModify(BaseModel):
    member_pw: Optional[str] = Field(title="사용자 패스워드")
    member_name: Optional[str] = Field(title="사용자 이름", max_length=20)
    member_email: Optional[str] = Field(title="사용자 이메일", max_length=50)

    class Config:
        orm_mode = True

# JWT Decode에 얻어지는 정보에 대한 Schema
class JWTPayload(BaseModel):
    member_no: int = Field(title="사용자 번호")
    member_id: str = Field(title="사용자 아이디")
    member_name: str = Field(title="사용자 이름")
    member_email: str = Field(title="사용자 이메일")

# 로그인 성공시 Response로 전송되는 Schema
class LoginResponse(BaseModel):
    member_no: int = Field(title="사용자 번호")
    member_id: str = Field(title="사용자 아이디")
    member_name: str = Field(title="사용자 이름")
    member_email: str = Field(title="사용자 이메일")
    access_token: str = Field(title="Access Token")
    refresh_token: str = Field(title="Refresh Token")

class Message(BaseModel):
    message: str

기존의 Member 클래스는 MemberCreate로 이름을 변경하고 이 클래스는 회원 정보를 추가할 때만 사용하도록 수정했다.

그러면서 회원 정보 수정시에 사용되는 MemberModify 클래스를 추가하였다. MemberCreate와 MemberModify의 차이는 id를 받을지에 대한 여부와 Create에서는 모든 변수가 필수값이지만 Modify에서는 모든 값이 옵션이라는 점이다.

그리고 JWT Decode 되어서 나오는 Dictionary로 별도의 Class로 선언하여서 라우터에서 받을 수 있도록 Schema로 선언하였다.

728x90

Router

이제 라우터를 수정해보도록 할 것이다.

members.py의 내용을 아래와 같이 변경한다.

from fastapi import APIRouter, Depends, Body
from sqlalchemy.orm import Session
from starlette.responses import Response, JSONResponse
from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_400_BAD_REQUEST
from typing import Optional
from ..database.connection import get_db
from ..database import schemas
from ..libraries import auth

router = APIRouter(
    prefix="/members",
    tags=["member"],
    responses={
        404: {"description": "Not Found"},
    }
)


# 회원 가입
@router.post("/create", description="회원 가입", response_class=Response, responses={
    HTTP_400_BAD_REQUEST: {
        "model": schemas.Message
    }
})
async def create(
    member: schemas.MemberCreate = Body(
        title="회원정보",
        example={
            "member_id": "foo",
            "member_pw": "1234567890",
            "member_name": "홍길동",
            "member_email": "test@example.com",
        }
    ),
    db: Session = Depends(get_db)
):
    try:
        return Response(status_code=HTTP_201_CREATED)
    except Exception as e:
        return JSONResponse(content={"detail": e.args[0]}, status_code=HTTP_400_BAD_REQUEST)


# 로그인
@router.post("/login", description="로그인", response_model=schemas.LoginResponse, responses={
    HTTP_400_BAD_REQUEST: {
        "model": schemas.Message
    }
})
async def login(
    member_id: str = Body(title="사용자 아이디"),
    member_pw: str = Body(title="사용자 패스워드"),
    db: Session = Depends(get_db)
):
    try:
        return result
    except Exception as e:
        return JSONResponse(content={"detail": e.args[0]}, status_code=HTTP_400_BAD_REQUEST)


# 회원 정보 수정
@router.put("/modify", description="회원 정보 수정", response_class=Response, responses={
    HTTP_400_BAD_REQUEST: {
        "model": schemas.Message
    }
})
async def modify(
    payload: schemas.JWTPayload = Depends(auth.decode_access_token),
    member: schemas.MemberModify = Body(
        title="수정할 회원정보",
        example={
            "member_pw": "1234567890",
            "member_name": "홍길동",
            "member_email": "test@example.com",
        }
    ),
    db: Session = Depends(get_db)
):
    try:
        return Response(status_code=HTTP_200_OK)
    except Exception as e:
        return JSONResponse(content={"detail": e.args[0]}, status_code=HTTP_400_BAD_REQUEST)


# 회원탈퇴
@router.post("/unsubscribing", description="회원탈퇴", response_class=Response, responses={
    HTTP_400_BAD_REQUEST: {
        "model": schemas.Message
    }
})
async def unsubscribing(
    payload: schemas.JWTPayload = Depends(auth.decode_access_token),
    db: Session = Depends(get_db)
):
    try:
        return Response(status_code=HTTP_200_OK)
    except Exception as e:
        return JSONResponse(content={"detail": e.args[0]}, status_code=HTTP_400_BAD_REQUEST)

아직 DB 작업에 대한 내용을 구현되지 않았으니 router에서 액션을 호출하는 일은 없다.

대신 비즈니스 로직을 구현하기 전에 먼저 Exception이 발생하는 경우에 대한 예외 처리를 먼저 추가했다.

그리고 reseponses를 추가해서 Exception이 발생했을 때 전달할 Response 타입을 미리 정의한다.

router를 수정한 후에 redoc 페이지를 확인하면 아래와 같이 작동하는 것을 확인할 수 있다.

회원가입
로그인
회원 정보 수정
회원탈퇴

 

반응형

검증(auth)

마지막으로 jwt 인증과 관련 코드도 약간 수정하였다.

from jose import JWTError, jwt
from fastapi import HTTPException, status, Header

# JWT 생성 함수
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        expire = datetime.utcnow() + timedelta(minutes=15)
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt


# JWT 검증 함수
def decode_access_token(token: str = Header("token")):
    encode = token.split(" ")
    if len(encode) != 2 or encode[0] != 'Bearer':
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid authentication credentials",
            headers={"WWW-Authenticate": "Bearer"},
        )

    try:
        payload = jwt.decode(encode[1], SECRET_KEY, algorithms=[ALGORITHM])
        return payload
    except JWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid authentication credentials",
            headers={"WWW-Authenticate": "Bearer"},
        )

JWT 검증을 하기 위한 decode_access_token에서 파라미터로 전달하는 token의 값은 request의 header에서 꺼내서 사용하도록 하였다.

더불어 token은 "Bearer"로 시작하여야 하도록 검사하는 코드를 추가하였다.

이 decode_access_token에서 종료후에 전달되는 payload는 위의 Schema에서 정의한 JWTPayload가 될 것이다.

이렇게 하면 회원 관련 기능을 구현하면서 변경된 부분을 추가로 정리하였다.

다음에는 포스팅에서는 마지막으로 라우터에서 호출할 비즈니스 로직을 담당하는 service 영역에 대한 코드를 작성하도록 하겠다.

 

728x90
반응형