글 생성 시에 비밀번호를 입력받는다면, 수정과 삭제에도 비밀번호 기능이 있어야 하기 때문에 추가했더니 다양한 에러가 발생했었다. 최종적으로 모두 해결했기에 기록하는 글.✍
대부분의 코드는 지난 글의 내용과 비슷하기 때문에, 지난 글 링크도 같이 첨부한다.
※ Fast API에서 비밀번호 해싱하기 - https://hjinn0813.tistory.com/151
Fast API에서 비밀번호 해싱하기
CRUD가 되는 게시판에서 글 생성 시에 비밀번호 해싱하기
hjinn0813.tistory.com
UnknownHashError 트러블슈팅
게시판 프로젝트에 비밀번호 기능을 추가하고 실험하면서 다양한 에러가 있었는데, 해결까지 가장 시간이 오래 걸렸던게 "passlib.exc.UnknownHashError: hash could not be identified"였다. 에러의 원인과 코드 설명을 하기에 앞서, 일단 나의 게시판 페이지 작동 구조는 아래와 같다.
- 글 생성 시에 이름, 비밀번호, 제목, 내용 입력하고 [확인] 클릭.
- 새로운 글이 생성되며 게시판 메인으로 이동되고, 글 제목을 클릭하면 디테일 페이지로 이동.
- 디테일에서 수정/삭제를 클릭하면 주소창에 쿼리스트링으로 모드가 들어가면서 '비번 입력창'으로 이동.
- 비번 입력창에서 자신의 비번을 작성하고 [확인] 클릭하면,
→ 수정 모드: 수정 가능한 form 페이지로 이동.
→ 삭제 모드: 실제로 글이 서버에서 삭제되는 로직 수행. - 수정: form에서 이름, 제목, 내용은 서버에서 가져온 값을 보여주지만 비밀번호는 빈 input창을 보여준다.
→ 자신이 글을 생성할 당시에 입력했던 비밀번호와 같은 값을 다시 입력해야 수정 가능.
처음 글을 생성할 때는 문자열 비번을 입력하면 백엔드 코드에서 제대로 해시값으로 변환해서 저장하고 있는데, 글 생성이 완료된 후에 글 수정까지 정상적으로 하고 나서, 디테일에서 [삭제] 버튼을 클릭해 비번입력창으로 가면, 맞는 비번을 작성해도 앞서 언급한 "passlib.exc.UnknownHashError: hash could not be identified" 에러가 발생했다. 에러의 발생 이유가 이해되지 않았는데, 코드에 일일이 print()를 넣어서 비번이 해시값으로 나오는지 확인해보고 원인을 알았다.💡
→ 내가 제대로 작동되는지 실험할 때 수정부터 해보고 삭제를 진행했었고..
글을 수정하는 과정에서 입력했던 문자열 비번이 새로운 해시가 아니라 문자열 그대로 저장되었다.
그러므로 내가 이후에 글을 삭제하려고 '비번입력창'에서 맞는 비번을 작성해도, 백엔드 코드에는 화면에 문자열로 입력된 비번이랑 서버에 저장된 해시 비번을 비교하라고 적혀있는데, 서버에는 글이 수정되면서 해시 비번이 사라져서 문자열 비번 밖에 없으니까 에러가 났던거였다!
글을 수정하면 비번 해시가 풀리는게 에러의 원인이라는걸 알았기 때문에, 글을 수정하는 CRUD 함수에 비밀번호 해시가 풀리지 않도록 추가적인 코드를 작성했다. 그리하여 최종 완성된 코드를 설명해보도록 하겠다.
비밀번호 검증 모델 클래스 추가
# 비번 검증 모델
class PwVerify(BaseModel):
post_id: int
password: str
model.py는 위에 링크를 첨부한 '비밀번호 해싱하기' 글의 내용과 거의 같은데, 비번입력창의 작동을 위해 비밀번호 검증을 위한 모델 클래스를 추가했다.
Pydantic의 BaseModel을 상속받아서, 비밀번호 검증에 필요한 id와 password를 받는다.
이를 통해 특정 게시글의 id와 비밀번호를 확인하여 수정/삭제를 진행한다.
수정/삭제 CRUD에 비밀번호 관련 로직 작성
crud.py에 수정/삭제에서 비밀번호는 어떻게 처리되는지 로직을 작성한다.
이 부분 코드에 대한 설명은 지난번 작성한 글에 상세히 기록되어 있다.
https://hjinn0813.tistory.com/149
DB에 CRUD가 되는 게시판 만들기 - Backend
나중에 풀스택으로 성장하고 싶다고 말했지만, 이렇게 빨리 백엔드를 배우게 될 줄은 몰랐다.😂
hjinn0813.tistory.com
# PUT: 게시글 수정
def update_post(db: Session, post_id: int, board: BoardUpdate, password: str):
post = db.query(Board).filter(Board.id == post_id).first()
if post is None:
raise HTTPException(status_code=404, detail="Post not found")
if not pwd_context.verify(password, post.password):
return {"success": False, "message": "Incorrect password"}
for key, value in board.dict(exclude_unset=True).items():
setattr(post, key, value)
# 비밀번호 수정이 있다면 해시로 저장
if board.password:
post.password = pwd_context.hash(board.password)
db.commit()
db.refresh(post)
return post
가장 먼저 수정과 관련된 로직이다.
- db는 데이터베이스와 연결된 세션 객체이고, post_id는 수정할 게시글의 ID, board는 수정할 내용을 담은 BoardUpdate 모델이다. 클라이언트로부터 비밀번호를 문자열로 입력받는다.
- 데이터베이스에서 Board 모델 클래스를 쿼리하여 post_id에 해당하는 게시글을 찾고, 없다면 HTTP 예외를 발생시킨다.
- pwd_context 객체와 verify() 메서드로 클라이언트에서 입력된 문자열 비번이랑 서버에 저장된 해시값 비번을 비교한다. 만약에 클라이언트에서 입력한 비번이 서버에 저장된 비번이랑 맞지 않으면 {"success": False, "message": "Incorrect password"}라는 메시지를 반환한다.
- board를 딕셔너리로 변환해 수정된 필드만 추려내고, setattr()로 post에 반영한다.
근데 이 과정에서 비어있는 비밀번호 input에 문자열 비번을 입력하면, 서버는 '비밀번호'의 값도 수정되었다고 생각해서 문자열 그대로 저장한다. 내 코드에서 UnknownHashError가 발생했던 것도 여기서 입력한 비번이 문자열로 서버에 저장됐기 때문이었다. - 그러므로 해시가 풀리지 않도록, 만약 앞서 딕셔너리로 저장한 비밀번호(board.password)가 해시가 아니면 post.password에 해시로 저장되도록 설정한다.
- db.commit()으로 데이터베이스에 변경 사항을 확정한다.
- db.refresh()로 업데이트된 post의 최신 상태를 다시 불러온다.
- 최종적으로 수정된 게시글을 반환하여 클라이언트에 전송한다.
# DELETE: 게시글 삭제
def delete_post(db: Session, post_id: int, password: str):
post = db.query(Board).filter(Board.id == post_id).first()
if post is None:
raise HTTPException(status_code=404, detail="Post not found")
if not pwd_context.verify(password, post.password):
return {"success": False, "message": "Incorrect password"}
db.delete(post)
db.commit()
return {"detail": "글 삭제 성공!"}
이번에는 삭제와 관련된 로직이다.
- db는 데이터베이스와 연결된 세션 객체이다. 삭제할 게시글의 ID(post_id)는 정수형으로, 비밀번호는 str 타입으로 클라이언트에서 받아온다.
- 데이터베이스에서 Board 모델 클래스를 쿼리하여 ID가 일치하는 게시글을 찾고, 없으면 HTTP 예외를 발생시킨다.
- pwd_context 객체와 verify() 메서드를 사용해서, 클라이언트에서 입력한 문자열 비번이랑 서버에 저장된 해시값 비번을 비교한다. 만약에 비번이 틀렸다면 {"success": False, "message": "Incorrect password"} 메시지를 보여준다.
- 앞서 3번에서 비번 검증에 통과하면, delete() 메서드로 post 변수에 담아놓은 게시글을 데이터베이스에서 삭제한다. 이 메서드는 삭제할 객체를 인자로 받아 해당 객체를 데이터베이스에서 제거한다.
- commit() 메서드로 데이터베이스에 변경사항을 확정한다.
- 최종적으로 "글 삭제 성공!"이라는 메시지를 클라이언트에게 반환하여, 요청이 성공적으로 처리되었음을 알린다.
엔드포인트 작성
다음으로는 api.py 파일에 엔드포인트를 작성한다.
수정/삭제와 관련된 엔드포인트는 기존에 작성했던 코드에서 비밀번호를 어디서 어떻게 가져올건지, 예외처리를 어떻게 할건지 2가지 부분을 추가적으로 작성했다. 비밀번호 기능을 추가하면서 '비번입력창'이라는 새로운 페이지가 생겼기 때문에 비번 검증 엔드포인트도 새로 추가되었다.
# 글 수정
@router.put("/board/{post_id}")
async def update_post_endpoint(post_id: int, board: BoardUpdate, req: Request, db: Session = Depends(get_db)):
body = await req.json() # body로부터 비밀번호 받기
password = body.get('password') # 비밀번호 추출
if not password:
raise HTTPException(status_code=400, detail="Password is required")
return update_post(db, post_id, board, password)
# 비밀번호는 body에서 받아옴
- 클라이언트에서 글의 id는 정수형으로 받고, board는 BoardUpdate 모델을 받아온다.
db는 db.py 파일의 의존성 함수 get_db()에서 생성된 데이터베이스 세션이다.
req: Request는 클라이언트로부터 받은 요청의 전체 정보를 담고 있는 객체로, req가 FastAPI의 Request 클래스 타입이라는 의미이다. FastAPI는 타입을 보고 클라이언트의 요청 정보를 req에 담아 전달해준다. 여기서는 비밀번호를 포함한 요청 바디를 가져오는데 사용된다. - HTTP 요청 바디에서 비밀번호를 받아서 추출한다. 이를 통해 클라이언트가 입력한 비밀번호를 서버가 확인할 수 있다.
- 만약 비밀번호 입력값이 비어있으면, 400 상태 코드와 함께 HTTP 예외를 발생시킨다.
- crud.py에서 가져온 update_post() 함수에 db, post_id, board, password를 인자로 넣어 해당 함수가 실행된 결과를 반환한다.
# 글 삭제
@router.delete("/board/{post_id}")
async def delete_post_endpoint(post_id: int, req: Request, db: Session = Depends(get_db)):
body = await req.json() # body로부터 비밀번호 받기
password = body.get('password') # 비밀번호 추출
if not password:
raise HTTPException(status_code=400, detail="Password is required")
return delete_post(db, post_id, password)
# 비밀번호는 body에서 받아옴
- 글의 id는 클라이언트에서 정수형으로 받고, db는 의존성 함수 get_db()에서 가져온 데이터베이스 세션이다.
req: Request는 클라이언트 요청의 전체 정보를 담고 있는 객체로, 비밀번호가 포함된 요청 바디를 가져온다. - HTTP 요청 바디에서 비밀번호를 받아서 추출한다. 이를 통해 클라이언트가 입력한 비밀번호를 서버가 확인할 수 있다.
- 비밀번호 입력값이 비어있으면, 400 상태 코드와 함께 HTTP 예외를 발생시킨다.
- crud.py에서 가져온 delete_post 함수에 db, post_id, password를 인자로 넣어 해당 함수가 실행된 결과를 반환한다.
# 비번입력창 - 비밀번호 검증 (백엔드 전용 엔드포인트)
@router.post("/verify_pw")
async def verify_pw(data: PwVerify, db: Session = Depends(get_db)):
post = db.query(Board).filter(Board.id == data.post_id).first()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
# 비밀번호 검증 (문자열 비번이랑 해시 비번 비교)
if pwd_context.verify(data.password, post.password):
return {"success": True}
else:
return {"success": False, "message": "Incorrect password"}
마지막으로 '비밀번호 입력창'에서 사용하는 비번 검증 전용 엔드포인트이다.
해당 엔드포인트는 백엔드에서만 사용되는 것으로, 화면에서는 보여지지 않는다.
- /verify_pw라는 엔드포인트는 post 요청을 보내서 비번을 검증한다.
- verify_pw() 함수에서 data는 model.py에 있는 PwVerify 모델 클래스에 정의된 데이터 구조를 사용한다.
db는 db.py의 의존성 함수 get_db()에서 가져온 데이터베이스 세션이다. - 데이터베이스에서 Board 모델 클래스를 쿼리하여 ID가 일치하는 게시글을 찾고, 없으면 HTTP 예외를 발생시킨다.
- pwd_context 객체와 verify() 메서드를 사용해서, 클라이언트에서 입력한 문자열 비번이랑 서버에 저장된 해시값 비번을 비교한다. 비번이 맞으면 {"success": True}를 반환하고, 비번이 틀렸다면 {"success": False, "message": "Incorrect password"} 메시지를 반환한다. 서버가 반환한 결과는 클라이언트에 전달되어, 클라이언트(React)는 resp.data.success 값을 확인하여 성공/실패에 따른 로직을 수행한다.
사실은 UnknownHashError를 어떻게 해결했는지 기록하려고 이 글을 작성하게 된거지만..
아무튼 여기까지가 Fast API로 비밀번호를 검증하는 방법이었다.
앞으로는 코드를 작성할 때, 까먹은 부분이 없는지 조금 더 순서대로 꼼꼼하게 살펴보도록 하자!😅

'💾 Backend > FastAPI' 카테고리의 다른 글
Fast API에서 비밀번호 해싱하기 (0) | 2024.11.08 |
---|---|
DB에 CRUD가 되는 게시판 만들기 - Backend (1) | 2024.11.04 |
FastAPI와 MariaDB 연동하기 (0) | 2024.10.30 |
Fast API 가상환경 구축하고 API 만들기 (2) | 2024.10.23 |
Fast API 기본 개념 정리 (1) | 2024.10.11 |