[REAL Python – Flask] – “인스타그램 클론코딩 – Instagram Clone (4)”
[REAL Python – Flask] – “인스타그램 클론코딩 – Instagram Clone (4)”
그래서, JWT가 대체 뭔데?
JWT란 과연 무엇일까요? 그것은 오늘 구현할 것이기도 하고, 로그인을 위해서 사용할 것이기도 합니다. 대체 JWT가 뭐길래, 로그인을 그렇게 구현하려고 하는 걸까요? 이에 대해서 이야기를 해 보겠습니다.
JWT – 뿌셔봅시다!
JWT(JSON Web Token)는 당사자 간에 정보를 JSON 개체로 안전하게 전송하기 위한 간결하고 자체 포함된 방법을 정의하는 개방형 표준( RFC 7519 )입니다.
https://jwt.io/introduction
대체 무슨 소리?
먼저, 우리의 flastagram 서비스 입장에서 생각해 봅시다.
사용자 “미미” 가 새로운 피드를 올렸습니다. 본인이 키우는 가오리를 자랑하는 글과 사진을 멋드러지게 올렸다고 가정합니다.
그런데, 잘 사는 “미미” 가 꼴보기 싫었던 “민수” 는 “미미” 올린 게시물을 삭제해 버렸고, “미미”는 눈물을 흘려 산타 할아버지께 선물을 못 받게 생겼습니다. 이런 대참사가 일어난 이유는, 서버가 “미미” 의 게시물을 “민수” 가 삭제하도록 허용해 버렸기 때문입니다.
이처럼, 우리가 구현할 서비스에서는 “민수” 가 “미미” 의 게시물을 삭제하거나 수정하도록 허용해서는 안 됩니다. 그렇기 위해서 기본적으로 필요한 것은, “이 피드를 삭제하려고 하는 사람이 누구인가?” 를 아는 것이고, 이는 결국 “서버는 해당 요청을 보내는 사람이 누구인지 알아야 한다” 가 됩니다. 만약 게시물을 삭제하려고 하는 사람이 “미미” 라면 삭제를 허용하고, “민수” 라면 삭제를 허용해서는 안 되겠죠. 크리스마스 선물은 소중합니다.
그것을 하기 위해서 대부분의 서비스에서 우리는 아이디, 비밀번호 등을 사용해 로그인을 진행합니다. 지메일을 예로 들어 볼까요. 우리가 이메일과 비밀번호를 사용해서 로그인을 수행하고 내 메일함에 접속하면, 서버는 “나”가 누구인지 식별하고 “나” 의 메일함에 있는 메일들을 볼 수 있도록 해 줍니다. 이렇게 “어떤 유저는 뭐까지 해도 돼!” 를 정하는 것을 인가라고 하는데, 그것은 나중에 다뤄 보겠습니다. 인증은, “나 서울 사는 미미요~” 를 서버에 증명하는 것입니다.
그런데 그것을 어떻게 구현할까요? 이쯤에서 JWT를 소개하고자 합니다. 아래의 설명은 제가 이해한 방법을 글로 작성한 것입니다.
JSON Web Token 의 줄임말임은 위에서 이미 알아차렸을 겁니다. JWT는 header, payload, signature 의 세 부분으로 나뉘고 각각을 . 으로 연결합니다. JWT의 기본적인 형태는 아래와 같습니다. 일단 “header, payload, signature 라는 게 있구나 정도만 하고 넘어가” 봅시다.
어쩌고저쩌고.저쩌고어쩌고.고쩌어고쩌저
실제 jwt.io 에 가서 형태를 확인해 봅시다.
뭔가 굉장히 알 수 없는 형태로 되어 있네요. 이는 헤더, 페이로드, 서명이 각각 Base64url 로 인코딩되었기 때문입니다. 그냥 “원래 값들을, 어떤 형태로 바꾸어 주었구나” 정도로 이해해 봅시다. 이를 Base64Url 디코더로 디코딩해 보세요. 위의 빨간색 부분, 보라색 부분을 디코딩하면,
헤더 부분을 디코딩하면 헤더의 정보가 나와 있네요. 서명 알고리즘은 HS256, 이것의 타입은 JWT임을 알려주고 있습니다.
페이로드 부분을 디코딩하면 페이로드의 정보가,
서명 부분을 디코딩하면, 서명의 정보가.. 조금 이상하게 나오게 됩니다.
이는, JWT가 사용하는 서명 알고리즘과 관련이 있습니다. 실제로 BASE64url 로 인코딩된 것도 맞고 디코딩할 수 있는 것도 맞지만 인쇄될 수 없는 숫자 값이 나오기 때문에 표시되지 않습니다. https://stackoverflow.com/questions/57053553/how-to-decode-signature-part-of-json-web-token
위의 경우, 서명 은 (헤더+페이로드+비밀 키) 를 HS256 서명 알고리즘을 통해 서명되었습니다.
HS256 알고리즘은 내부적으로 SHA256 해시 알고리즘을 사용합니다. 해당 알고리즘의 특징은 단방향으로, 해싱된 값을 보고 원래 값을 알아낼 수 없다는 것입니다.
https://emn178.github.io/online-tools/sha256.html 에서 실습을 해 볼까요?
우리는 hello! 와 같이 아주 간단한 문자열을 넣었지만 해시 알고리즘에 의하여 알 수 없는 값으로 변환되었고 사람들은 이제부터 아래의 값을 보고 “어 이거 원래 값이 hello네 ㅋㅋ” 를 알 수 없습니다. 단방향이기 때문입니다.
HS256 서명 알고리즘은, (헤더+페이로드+서버의 비밀 키)를 SHA256 해시 알고리즘을 통해 해싱합니다. 그리고 그것을 Base64url 인코딩한 값이, 위에서 보이는 JWT의 세 번째 부분입니다.
그럼 로그인은 어떻게 이루어지고, 해당 토큰은 어떻게 검증될까요? 먼저, 위의 토큰을 클라이언트가 서버에게 보내 주었다고 가정합니다.
토큰을 받은 서버는, 그리고 토큰을 탈취한 누구던 위의 값을 Base64url decode 를 해서 알아낼 수 있습니다. 이는 우리가 JWT에 민감한 정보를 담으면 안 되는 이유이기도 합니다.
- 그러면, 서버는 위의 페이로드와 헤더, 서버의 비밀 키를 이용해서 똑같은 서명을 만들어내고 이것이 JWT에 담긴 서명과 같은지를 검증합니다.
- 만약 누군가가, “이 서버의 JWT는 헤더에는 어떤 거, 페이로드에는 어떤 거, 시크릿 키는 무엇일 거야!” 를 가정하여 HS256 알고리즘으로 서명을 해서 우리에게 보내주었다고 가정합시다.
- 서버는 서버가 가지고 있는 시크릿 키를 알고 있으므로, 그것을 사용해서 서명을 만들어낸 후 “어 이 자식.. 서명이 다르잖아?” 를 알아내고, “이 자식은 서울 사는 미미가 아니에요 ..” 를 판단할 수 있게 됩니다.
- 만약 토큰이 유효하다면, “이 자식은 서울 사는 미미니까, ~~~는 할 수 있게 해 주세요~” 를 판단하게 됩니다.
다소 난잡하지만 설명이 되었으면 좋겠습니다. 요약하자면 서버는 JWT를 발급하고, 클라이언트는 그것을 어딘가에 저장해 둔 다음, 서버에게 요청을 보낼 때마다 운전면허증 내밀듯이 “나는 ~~다~!” 를 알려줄 수 있게 되는 겁니다.
그런데, 문제가 생깁니다. 만약 누군가가 위의 토큰 값을 가져가 버리면 어떻게 하지? 가 되는 겁니다. 누군가가 토큰을 가져갔다는 것은 그것의 서명도 가져가 버렸다는 것이 되고, 그러면 “철수” 도 “나 미미입니다~” 를 서버에 증명할 수 있는 상황이 되어 버립니다. 누군가가 내 운전면허증을 가져가 “나 미미입니다..ㅎㅎ..” 를 시전하며 사람들에게 욕을 하고 다니고, 민폐를 끼치고 다닌다면 엄청 끔찍하겠죠. 이를 막아야 합니다.
이 문제를 해결하기 위해서 access token, refresh token 이 등장합니다. 위에서 지금까지 클라이언트에게 발급해 준 토큰을 access token이라고 합시다. 이 액세스 토큰은 누군가가 그 값을 가져가 버리면 속수무책으로 그 사람 또한 나라는 것을 인증할 수 있게 됩니다. 대부분의 비유가 그렇듯 이해를 위해 상세한 사실관계를 제물로 바치지만, 액세스 토큰을 “나의 운전면허증” 이라고 가정하겠습니다. 운전면허를 발급받을 때, “운전면허증의 영수증” 도 같이 받았다고도 가정해 봅시다.
운전면허증을 도용하고 다니는 탓에, 나라에서는 “운전면허증의 유효기간은 3일이고, 그것을 다시 받아서 운전하고 싶으면 근처 동사무소로 가서 재발급을 받아라” 라고 해 버립니다. 운전을 하기 위해서 3일마다 동사무소에 찾아가 운전면허증을 발급받아야 한다니, 좀 끔찍합니다. 동사무소에 가서, 자신이라는 것을 인증받고, 운전면허증을 다시 받아야 한다니요. 대중교통 타고 다니겠습니다.
refresh token(운전면허증의 영수증) 은 access token(운전면허증) 을 재발급받기 위한 토큰입니다. 그것은 서버에 저장되는 값이기도 합니다. 서버에서 리프레시 토큰을 저장하고, 클라이언트가 리프레시 토큰을 들이밀며 “나 서울 사는 미미 맞으니, 운전면허증 다시 발급해 줘..” 라고 요청합니다. 서버는 해당 리프레시 토큰을 검증하고, 데이터베이스에 저장되어 있는 “미미의 리프레시 토큰” 이 맞다면 “미미의 운전면허증” 을 다시 발급해 줄 겁니다. 영수증은 “내가 운전면허증을 받았다는 것” 을 증명해 주므로, 동사무소에 가서 일일이 자기 자신이라는 것을 인증할 필요가 없죠. 단지 동사무소에 가서 “영수증” 만 들이밀면 운전면허증을 발급해 줄 겁니다.
이쯤에서 이런 의문이 생길 수 있습니다. “그러면 누가 리프레시 토큰 가져가 버리면 운전면허증 무한으로 다시 발급받는 건데 대체 뭔 소용임?” 일 겁니다. 하지만, 리프레시 토큰은 운전면허증과는 다르게 데이터베이스에 저장된다는 점이 다릅니다. 서버의 내부 정책으로 그것을 삭제할 수 있죠. 만약 리프레시 토큰을 사용해서 어제는 한국 부산 어딘가에서, 오늘은 미국 샌프란시스코 어딘가에서 운전면허증을 발급해달라고 요청을 해 오면, 서버는 “리프레시 토큰 탈취당했네..” 를 알아챈 후 그것을 데이터베이스에서 삭제하거나, 블랙리스트에 추가하는 방식으로 로그인을 하지 못하게 할 수 있습니다. 아래에서, 우리는 “클라이언트가 이메일, 비밀번호를 할 때마다 리프레시 토큰을 재발급” 하고 그것을 데이터베이스에 업데이트 할 것입니다. 만약 데이터베이스에 없는 리프레시 토큰으로 액세스 토큰을 요청하면, 우리는 그것에 대한 액세스 토큰 발급을 허용하지 않겠습니다.
JWT – 구현해 봅시다!
먼저, 대략적인 흐름을 상상해 봅시다. 유저는 우리에게 “이메일, 비밀번호” 를 통해서 로그인을 요청할 것이고, 우리는 “이메일 ,비밀번호 모두 맞네, 액세스 토큰이랑 리프레시 토큰 발급해 줄게!” 가 될 겁니다. 클라이언트는 우리에게 어떤 정보를 담아 POST 요청을 보내줄 거고, 그러면 resource.user.py 를 손봐야겠다는 생각을 할 수 있겠네요!
필요한 것들을 모두 import 해 줍니다.
그리고 위의 코드를 작성합니다. MethoView 는 Flask의 Pluggable View의 가장 기본이 되는 클래스이고, post() 메서드를 정의함으로서 “아, 이 리소스는 post 요청에 대해서 다룰 수 있구나!” 를 알 수 있게 됩니다.
먼저 클라이언트는 우리에게 아래와 같은 데이터를 보내 줄 겁니다.
{
"email" : "어쩌고저쩌고@example.com",
"password" : "hello2122"
}
그러면, 서버는 위의 이메일을 통해서 유저를 찾습니다. 우리가 모델 단에서 정의했던 메서드, find_by_email() 을 기억하시나요?
이메일로 특정 사용자를 찾고, 특정 사용자가 존재하지 않으면 “이메일과 비밀번호를 확인해 주세요” 라는 에러 메시지를, 그렇지 않으면 유저에 대한 비밀번호가 클라이언트가 보낸 비밀번호와 맞는지 검증할 겁니다. 이는 hash 되어 저장되므로, werkzeug.check_password_hash() 메서드를 통해서 검증합니다. ( 해시된 비밀번호를 원래 값으로 바꿔 저장하지 않고, 데이터베이스에 있는 값을 해싱하여 그 둘을 비교합니다. 단방향이기 때문입니다.)
그리고, 클라이언트가 보낸 비밀번호 정보가 데이터베이스에 있는 유저 정보의 비밀번호와 일치한다면 리프레시 토큰과 액세스 토큰을 발급해 줄 겁니다.
사실 코드 자체가 직관적이라 바로 이해할 수 있을 것이라 생각합니다. :)
이제, 우리가 만들어 둔 리소스를 등록해 줍니다. (backend/api/__init__.py 안의 create_app() 함수입니다!)
만약 사용자가 잘못된 이메일이나 비밀번호를 보냈다면 위와 같이 응답해 줄 겁니다. 상태 코드 401으로 응답해 주는 것이 적절해 보입니다.
적절한 이메일과 비밀번호를 보내 준다면, 아래와 같이 리프레시 토큰과 엑세스 토큰을 발급해 줄 겁니다.
위에서 발급받은 access token 과 refresh token 을 jwt.io 에 디코딩해 봅시다.
마찬가지로 refresh token도 디코딩해 본다면 유저가 잘 뜨는 걸 확인할 수 있을 겁니다!
JWT – 발급은 했는데, 그래서 이제 뭐 함?
이제 우리는 적어도 로그인하는 사람이 누구인지 알게 됐습니다. “미미로 로그인했네!” 까지란 거네요. 이제 “미미로 로그인했으니, 네가 쓴 글은 수정이나 삭제할 수 있고, ‘철수’ 가 쓴 글은 수정하거나 삭제할 수 없어!” 를 설정해 주겠습니다.
먼저, flask-jwt-extended 에서는 “이곳에 접근하려면 jwt가 필요해!” 를 알려주는 데코레이터인 jwt_required() 를 지원합니다. 구현은 아래와 같네요.
그리고, 에러 메시지를 다루기 위해서 api/__init__.py 에 에러 메시지를 추가해 주겠습니다.
@jwt.expired_token_loader
def expired_token_callback(jwt_header, jwt_payload):
"""
토큰이 만료되었을 때의 에러 메시지를 지정합니다.
"""
return (
jsonify({"Error": "토큰이 만료되었습니다."}),
401,
)
@jwt.invalid_token_loader
def invalid_token_callback(error):
"""
토큰이 잘못된 값일 때의 에러 메시지를 지정합니다.
"""
return (
jsonify({"Error": "잘못된 토큰입니다."}),
401,
)
@jwt.unauthorized_loader
def missing_token_callback(error):
""" """
return (
jsonify(
{
"Error": "토큰 정보가 필요합니다.",
}
),
401,
)
create_app() 아래에 위의 코드를 추가합니다.
에러 메시지를 한글로 표시해주기 위해서 위의 코드도 추가합니다.
이제 “이 엔드포인트에 접근하려면, jwt 를 제출해!” 를 추가할 차례입니다. 기본적으로 게시물 상세 조회, 게시물 목록 조회에 대해서는 로그인하지 않아도 조회를 가능하게끔 작업하겠습니다. 나머지 부분(게시물에 대한 수정, 삭제, 추가)은 jwt가 헤더로 전달되어야지만 수행이 가능하게끔 할 겁니다.
그러면 위와 같이 구현할 수 있습니다. PostList에 대해서는, post 요청에 대해서만 jwt를 제한하면 되겠죠.
PostDetail에 대해서는 put, delete 에 대해서 jwt가 필요하다는 장식자를 붙여 주겠습니다.
그리고 postman 에 가서 테스트를 수행해 보겠습니다.
먼저 새로운 유저 “미미” 를 회원가입시키겠습니다.
그리고 “미미” 로 글을 생성하려고 하면 “토큰 정보가 필요합니다.” 라는 에러 메시지를 뿌려 줍니다. 의도한 대로입니다. 인증을 추가하여 글을 작성할 수 있게끔 해 보겠습니다.
토큰을 얻기 위해서 로그인을 진행합니다. 위의 “access token” 을 클립보드에 복사합니다.
이후, POST posts/ 의 Authorization 에 Bearer Token 을, 토큰에는 Token 값을 넣어줍니다.
body 에는 위의 정보를 담아 POST 요청을 보내 봅시다. 성공적으로 게시물이 생성되었네요 :)
GET posts/ 을 확인해 보면, 게시물이 성공적으로 저장된 것을 확인할 수 있네요!
JWT – 리프레시 토큰 발급 구현
리프레시 토큰을 구현하는 방법에는 여러 가지가 있습니다. 필자는 그 중 안전하다고 생각한 방법을 구현한 것이니, 본인에 맞는 방식대로 구현해 보시고 그것들을 비교해 보시는 것도 추천드립니다. :)
위에서 access token 에 대해 알아볼 때, access token 에는 유효시간이 있다고 했습니다. 유효시간이 만료되면 해당 액세스 토큰으로는 로그인이 불가능하고, 액세스 토큰을 다시 발급받기 위해서 refresh token 을 사용한다고 했었죠. 추가로 그것을 데이터베이스에 저장할 것이라고도 했습니다. 이번에는 리프레시 토큰을 발급받는 과정을 구현해 보겠습니다.
- 전송받은 액세스 토큰이 만료되었음 -> 액세스 토큰이 만료되었다는 에러 메시지와 함께, 401 상태 코드 응답
- 액세스 토큰이 만료되었다는 응답을 받으면, 클라이언트는 서버에게 리프레시 토큰 전송
- 리프레시 토큰을 받은 서버는, 그것을 검증하고 액세스 토큰과 새로운 리프레시 토큰을 발급하고, 그것을 데이터베이스에 저장
- 클라이언트는 해당 액세스 토큰을 받아서 다시 요청
- 서버는 “아 이 친구는 미미구나!” 를 알게 됨
의 흐름이 되겠네요.
먼저, 리프레시 토큰은 발급 시 데이터베이스에 저장되어야 하므로, 데이터베이스 구조를 약간 수정하겠습니다.
리프레시 테이블을 저장하기 위한 테이블을 하나 만들어 주겠습니다. (models.user.py)
데이터베이스에 변경사항이 있으므로 flask db migrate -> flask db upgrade 를 수행해 줍니다.
새 테이블이 생성되었네요. ㅎ_ㅎ. 이제 리프레시 토큰을 발급할 때, 그것이 데이터베이스에 업데이트되도록 하겠습니다. Resources/user.py 에 아래의 코드를 작성하면 되겠습니다.
위의 로직에 따라서 해당 유저가 토큰을 가지고 있으면 새 토큰을 만들어 저장하고, 가지고 있지 않으면 새 토큰을 만들어 저장할 겁니다.
그러면, 이제 새 액세스 토큰을 발급해야 합니다. Flask-jwt-extended 에서는 아래와 같이 액세스 토큰을 발급할 수 있습니다. 액세스 토큰을 일회용으로 사용하도록 정했으므로, 우리는 리프레시 토큰을 사용하여 액세스 토큰을 발급하는 순간, 리프레시 토큰도 재발급한 후, 해당 리프레시 토큰을 데이터베이스에 저장하도록 하겠습니다. 만약 리프레시 토큰이 데이터베이스에 존재하지 않거나, 혹은 잘못된 값일 경우 에러 메시지를 출력하도록 합시다.
먼저, models.user.py 의 RefreshTokenModel에 아래의 메서드를 작성합니다. 이는 데이터베이스에 리프레시 토큰이 존재한다면 그에 맞는 사용자를 가져올 것이고, 그렇지 않다면 None 을 리턴할 겁니다.
그리고 Resources.user 에 아래의 코드를 작성합니다. 새 리프레시 토큰과 액세스 토큰을 발급하고, 리프레시 토큰을 데이터베이스에 업데이트합니다.
class RefreshToken(MethodView):
"""
Refresh Token 을 받아 검증하고,
새로운 Refresh Token, Access token 을 발급합니다.
Refresh Token 은 일회용이므로, 새로운 Refresh Token 이 발급되면
데이터베이스에 그 값을 저장합니다.
"""
@jwt_required(refresh=True)
def post(self):
"""
-> refresh token 은 이미 검증된 상태라고 가정 (틀린 토큰, 만료된 토큰 X)
-> 해당 유저가 데이터베이스에서 가지고 있는 refresh token 과 요청으로 들어온 refresh token이 다르다면,
-> access token 발급은 실패해야 함
"""
identity = get_jwt_identity()
token = dict(request.headers)["Authorization"][7:]
user = RefreshTokenModel.get_user_by_token(token)
if not user:
return {"Unauthorized": "Refresh Token은 2회 이상 사용될 수 없습니다."}, 401
# access token, refresh token 발급
access_token = create_access_token(fresh=True, identity=identity)
refresh_token = create_refresh_token(identity=user.username)
if user:
token = user.token[0]
token.refresh_token_value = refresh_token
token.save_to_db()
return {"access_token": access_token, "refresh_token": refresh_token}, 200
만약 이 곳에 만료되거나 데이터베이스에 존재하지 않는 리프레시 토큰을 전송했다면 서버는 다시 401 응답을 보내올 것이고, 그럼 클라이언트는 다시 비밀번호와 이메일을 입력함으로서 로그인을 진행해야 합니다.
이제, api.__init__.py 에 아래의 코드를 작성함으로서 액세스 토큰과 리프레시 토큰의 유효시간을 지정해 주고,
우리가 작성한 RefreshToken 리소스를 등록해 주겠습니다. /refresh/ 에 리프레시 토큰을 담아 전송하면, 우리는 새 리프레시 토큰과 액세스 토큰을 발급해 줄 겁니다.
우리가, 위에서 구현한 것들에 대해 잠깐 짚고 넘어가겠습니다.
- 로그인은 사용자가 서버에게 (이메일은 이거고, 비밀번호는 이거야!) 를 보내줌으로서 수행됩니다.
- 서버는 그것들을 받고, 이메일과 비밀번호가 적합하다면 “리프레시 토큰”, “액세스 토큰” 을 발급하고, 리프레시 토큰을 데이터베이스에 저장합니다.
- 액세스 토큰은 유효시간이 짧은 토큰입니다. 이는 데이터베이스에 저장되지 않습니다.
- 리프레시 토큰은 액세스 토큰에 비해 유효시간이 긴 토큰입니다. 이는 데이터베이스에 저장됩니다.
- 사용자는 액세스 토큰의 유효시간이 만료될 때까지, 어떤 요청을 보낼 때마다(게시물 작성해 줘!) 우리에게 액세스 토큰을 같이 담아 보낼 겁니다.(게시물 작성해 줘! 내 액세스 토큰인 이거야!)\
- 만약 액세스 토큰의 유효시간이 만료되었다면, 사용자는 리프레시 토큰을 사용하여 액세스 토큰과 리프레시 토큰을 다시 발급받습니다.
- 우리는, 리프레시 토큰을 1회용으로만 사용할 수 있다고 정했습니다. 그러므로, 데이터베이스에는 새 리프레시 토큰이 저장됩니다.
- 사용자는 위의 과정을 거치며 로그인을 하고 활동할 수 있습니다.
그렇기에, 제대로 구현했다면 아래의 과정에 막힘이 없어야 합니다.
첫째, 로그인을 진행하고, 두 종류의 토큰을 발급받습니다.
둘째, 위의 결과에서 리프레시 토큰을 복사합니다.
셋째, /refresh/ 에 위처럼 토큰을 담아 보내면, 새로운 액세스 토큰과 리프레시 토큰을 발급받을 수 있습니다.
넷째, 위의 결과로 나온 리프레시 토큰을 복사해 둡니다.
다섯째, 위에서 복사한 토큰을 담지 않고(아무것도 하지 않은 채로) 동일한 요청을 보내 보세요. 위와 같이 리프레시 토큰은 2회 이상 사용될 수 없다는 에러 메시지가 반겨줘야 합니다.
여섯째, /refresh/ 로 반환된 리프레시 토큰을 담아서 /refresh/ 에 다시 요청을 보내 보세요. 위와 같이 액세스 토큰과 리프레시 토큰을 발급받을 수 있을 겁니다.
이렇게 로그인을 구현할 수 있었네요. :)
게시물 작성 시, 저자가 자동으로 추가되도록 구현
돌고 돌아, 이제 “게시물 추가 시 작성자가 자동으로 추가되도록” 구현해 보겠습니다. 우리가 전에 구현했던 게시물 작성 API는, 아래의 데이터를 전송해야지만 저자가 정상으로 추가되었습니다.
author_id 를 입력하지 않고 게시물을 작성할 수 있다면 얼마나 좋을까요? 로그인 과정을 거치기 전까진 그렇게 하지 못 했지만, 이제는 그렇게 할 수 있습니다. 왜냐면, 로그인을 진행함으로서 “내가 미미야!” 를 서버에 알려줄 수 있기 때문입니다.
먼저, 게시물을 작성할 때에 우리는 저자의 id 를 입력하지 않을 것이므로, schemas/user.py 에 “user_id” 는 넣을 필요 없어~ 를 알려주겠습니다. 만약 허용한다면, 내가 “미미” 임에도 “철수의 id” 를 서버에 넘겨줌으로서 게시물의 저자를 “철수” 로 바꿔버릴 수 있기 때문입니다.
schemas.post.py 아래의 PostSchema 클래스의 메타 클래스를 아래와 같이 수정합니다.
이제, 서버는 “제목, 내용” 만 받을 수 있습니다. 게시물 저장에 대한 로직은 resources 에서 진행되므로, 아래와 같이 코드를 수정해 줍니다.
클라이언트는 게시물을 작성할 때에 우리에게 JWT를 보내 줄 것이고, 우리는 그것으로부터 유저 이름을 알아낼 수 있습니다.
유저 이름은 데이터베이스에서 유일한 값이므로, (unique=True) 유저 한 명을 특정할 수 있겠죠?
그러면, 그 유저에 대한 id도 얻어올 수 있을 것이고, 결과적으로 77번 줄처럼 author_id 를 정해줄 수 있습니다.
위와 같이 access token 을 담고, “title” 과 “content” 만 담아서 게시물 생성 요청을 보내 보세요. 위와 같이 잘 생성될 겁니다. :)
게시물 수정 시 저자 본인만 게시물을 수정할 수 있고, author_id 를 보내지 않아도 되도록 구현
개인적으로, 이 부분을 구현하는 데 있어서 삽질을 많이 했습니다. 나름 최상의 방법을 사용해서 구현해 본 것이니, 더 좋은 방법이 있다면 그대로 구현해도 좋습니다. :)
먼저 고민했던 내용은 PUT 메서드의 역할에 관한 내용입니다. 지금까지 구현했던 PUT 메서드의 로직은 아래와 같습니다.
클라이언트가 posts/1 에 PUT 요청을 보낸다. -> id로 특정되는 게시물이 있다면 수정한다. 혹은, id로 특정되는 게시물이 없다면, 하나 생성해낸다.
이전에 구현했던 자동차 CRUD api에서도 비슷한 로직을 PUT 메서드를 통해서 구현했었습니다. 예를 들면 데이터베이스에 (그랜저, 소나타) 라는 자동차가 있고, PUT 메서드로 /cars/다마스 에 요청을 보낸다면 서버는 “다마스” 라는 자동차를 생성해주었습니다. 그리고 실제로 /cars/다마스 에 GET 요청을 보낸다면, 서버는 {‘name’:’다마스’} 와 같은 자동차의 정보를 응답해 주었습니다.
posts/1 에 PUT 요청을 보내는 것과 cars/다마스 에 PUT 요청을 보내는 것의 근본적인 차이는, 게시물의 경우 url에서 게시물을 id 로 식별한다는 것입니다. id는 데이터베이스에서의 게시물의 primary key 이기 때문에, url 을 우리가 직접 결정하지 않습니다. 데이터베이스에 게시물이 새로 저장될 때마다, id는 1씩 늘어날 겁니다.
이해가 안 된다면, 아래의 상황을 가정해 봅시다.
- 사용자는 게시물을 하나 생성했습니다. /posts/ 에 POST 메서드를 통해서 요청을 보냈습니다. 이것이 데이터베이스에 들어갈 때는, id=1 로 들어갈 겁니다. 해당 게시물을 상세조회 하기 위해서는, posts/1 에 GET 요청을 보낸다면 서버는 사용자에게 1번 포스트의 상세내용을 보여줄 겁니다.
- 그리고, 해당 사용자는 /posts/3 에 PUT 메서드를 통해서 요청을 보냈습니다. 이 경우, 새로운 게시물은 생성되지만 데이터베이스에서의 id는 2가 될 겁니다. 이는 이상합니다.
- PUT posts/3 요청을 보냈다는 것은, “id=3″ 인 게시물이 있으면 수정하고, 없으면 id=3인 게시물을 생성해낸다” 라는 의미일 겁니다. 그런데, 데이터베이스에 만들어진 건 id=2 인 게시물입니다.
그렇기에, 지금의 인스타그램 클론코딩 프로젝트에서는 “없는 게시물을 수정하려고 하는 경우 404 응답을 한다” 로 로직을 변경하겠습니다.
먼저, api/models/post.py 에서 게시물을 수정하기 위한 update_to_db() 메서드를 작성하겠습니다.
models/post.py 의 PostModel 안에 해당 메서드를 작성합니다. 위에서 작성한 메서드는 data 라는 인자를 받을 겁니다. 그리고 그것의 자료형은 딕셔너리일 것이라고 가정합니다.
이후, data.items() 로 딕셔너리의 키, 값을 가져올 수 있을 겁니다. 바로, 반복문이 도는 동안 setattr() 메서드를 사용해 “해당 게시물의 () 라는 속성을 () 로 설정한다” 의 로직을 사용합니다.
@classmethod
@jwt_required()
def put(cls, id):
"""
게시물의 전체 내용을 받아서 게시물을 수정
없는 리소스를 수정하려고 한다면 HTTP 404 상태 코드와 에러 메시지를,
그렇지 않은 경우라면 수정을 진행
"""
post_json = request.get_json()
# first-fail 을 위한 입력 데이터 검증
validate_result = post_schema.validate(post_json)
if validate_result:
return validate_result, 400
username = get_jwt_identity()
author_id = UserModel.find_by_username(username).id
post = PostModel.find_by_id(id)
# 게시물의 존재 여부를 먼저 체크한다.
if not post:
return {"Error": "게시물을 찾을 수 없습니다."}, 404
# 게시물의 저자와, 요청을 보낸 사용자가 같다면 수정을 진행할 수 있다.
if post.author_id == author_id:
post.update_to_db(post_json)
else:
return {"Error": "게시물은 작성자만 수정할 수 있습니다."}, 403
return post_schema.dump(post), 200
모델 단에서 적절한 메서드를 작성해주었으므로, resource/post.py 의 Post 클래스의 put 메서드를 위와 같이 수정할 수 있습니다.
그러면, 게시물을 수정해 볼까요? 데이터베이스에 존재하는 19번 게시물을 수정해 보겠습니다.
위와 같이 PUT 요청을 보내면, 게시물을 수정할 수 있습니다.
그리고, 데이터베이스에 없는 20번 게시물을 수정하겠다는 요청을 보내면,
게시물을 찾을 수 없다는 에러를 내뿜네요.
그리고, “미미” 가 아닌 “철수” 의 access token 을 헤더에 첨부해 요청을 보내면, 위와 같이 게시물은 작성자만 수정할 수 있다는 에러 메시지를 성공적으로 보여주는 걸 확인할 수 있네요! :)