FastAPI 인증 — OAuth2와 JWT 토큰
"너 누구냐"를 묻는 로그인부터 "이거 해도 되냐"를 따지는 보호된 라우트까지. 비밀번호 해싱과 JWT 토큰으로 인증을 제대로 세운다.
지금까지 우리가 만든 API 는 누구나 마음대로 들어올 수 있는 문 이었다. /items 를 부르면 누구든 목록을 보고, POST 를 날리면 누구든 데이터를 쓴다. 작은 토이 프로젝트라면 괜찮지만, 진짜 서비스라면 곧장 질문이 생긴다 — "이 요청을 보낸 게 누구지? 이 사람이 이걸 봐도 되나?"
그 질문에 답하는 게 인증(authentication) 과 인가(authorization) 다. 9편은 FastAPI 의 표준 인증 방식인 OAuth2 + JWT 를 처음부터 끝까지 짠다. 회원의 비밀번호를 안전하게 저장하고, 로그인하면 토큰을 발급하고, 그 토큰이 있어야만 들어올 수 있는 라우트를 만든다. 보안은 한 번 헷갈리면 통째로 무너지는 영역이라, 흔한 실수들도 같이 짚는다.
1. 인증과 인가 — 헷갈리면 안 되는 두 단어
두 단어가 비슷해 보여서 자주 섞이는데, 한 문장으로 가르면 평생 안 헷갈린다.
인가(Authorization) = "너 이거 해도 되냐" — 권한을 확인하는 일. 김철수가 관리자 페이지에 들어갈 자격이 있는지 따지는 단계.
공항으로 비유하면 — 인증은 여권을 보여 주고 "본인이 맞다"를 확인받는 일이고, 인가는 그 여권에 찍힌 비자로 "이 나라에 들어갈 수 있다"를 확인받는 일이다. 순서도 항상 인증이 먼저다. 누구인지부터 알아야 그 사람이 뭘 할 수 있는지 따질 수 있으니까.
이번 편은 대부분 인증에 집중한다. "로그인한 사용자만 들어올 수 있다"까지가 9편의 목표고, 등급별 권한(관리자 vs 일반)을 나누는 인가는 그 위에 얹는 응용이다. 인증이 단단하면 인가는 조건문 한 줄로 끝난다.
2. 비밀번호 해싱 — 평문 저장은 사고다
인증의 출발점은 비밀번호다. 그런데 여기서 초보가 가장 크게 사고를 친다. 비밀번호를 DB 에 그대로(평문으로) 저장하는 것이다. 절대 안 된다. DB 가 한 번 털리면 모든 회원의 비밀번호가 그대로 노출되고, 사람들은 같은 비밀번호를 여러 사이트에 돌려쓰기 때문에 피해가 우리 서비스에서 끝나지 않는다.
해법은 해싱(hashing) 이다. 비밀번호를 되돌릴 수 없는 형태로 바꿔서 저장한다. mypassword123 이 $2b$12$Kx... 같은 긴 문자열이 되는데, 이건 원래 값으로 되돌릴 수 없다. 로그인할 때는 입력받은 비밀번호를 같은 방식으로 해싱해서, 저장된 해시와 일치하는지만 비교한다.
FastAPI 진영에서는 passlib 라이브러리에 bcrypt 알고리즘을 얹어 쓴다. 먼저 설치부터.
두 패키지를 한 번에 깔았다. passlib[bcrypt] 는 해싱용, python-jose[cryptography] 는 잠시 뒤 JWT 발급에 쓴다. 이제 해시를 만들고 검증하는 헬퍼를 짠다.
CryptContext 가 핵심이다. schemes=["bcrypt"] 는 "bcrypt 로 해싱하라"는 뜻이고, deprecated="auto" 는 나중에 더 강한 알고리즘으로 바꿀 때 옛 해시도 자동으로 인식하게 해 주는 옵션이다. hash_password 는 회원가입 때 호출하고, verify_password 는 로그인 때 입력값을 검증한다. bcrypt 는 같은 비밀번호라도 매번 다른 해시를 내놓지만(내부에 무작위 salt 가 섞인다), verify 가 그걸 알아서 처리하니 우리는 신경 쓸 게 없다.
MD5·SHA-1 같은 빠른 해시로 저장하면 안 된다. 이런 해시는 초당 수십억 번 대입하는 무차별 공격에 순식간에 뚫린다. 비밀번호 저장에는 일부러 느리게 설계된 bcrypt·argon2·scrypt 만 쓴다. ③ SECRET_KEY 나 해시를 코드·깃 저장소에 박지 말 것 — 반드시 환경변수로 뺀다.
3. 로그인 → JWT 발급
비밀번호 검증이 통과하면, 서버는 사용자에게 "너는 인증됐다"는 증표를 발급한다. 그 증표가 JWT(JSON Web Token) 다. 한 번 로그인하면 토큰을 받고, 이후 요청마다 그 토큰을 들고 오면 서버는 매번 비밀번호를 묻지 않아도 "아, 아까 그 사람이군" 하고 알아본다.
JWT 는 점(.) 으로 나뉜 세 토막의 문자열이다 — 헤더·페이로드·서명. 핵심은 마지막 서명 인데, 서버만 아는 SECRET_KEY 로 만들어진다. 누가 토큰 내용을 위조해도 서명이 안 맞으면 서버가 즉시 가짜라고 판별한다. FastAPI 는 로그인 폼을 받는 표준 도구 OAuth2PasswordRequestForm 을 제공한다.
한 흐름으로 읽으면 — ① OAuth2PasswordRequestForm 이 폼에서 username·password 를 꺼내 준다(OAuth2 규격이라 이메일을 받아도 필드명은 username 이다). ② DB 에서 사용자를 찾고 verify_password 로 비밀번호를 검증한다. ③ 틀리면 401 을 던진다. ④ 맞으면 jwt.encode 로 토큰을 만든다. 페이로드의 sub 는 "이 토큰의 주인"(보통 사용자 식별자), exp 는 만료 시각 이다.
exp(만료)가 중요한가 — 토큰이 영원히 유효하면, 한 번 새어 나간 토큰으로 영원히 로그인된다. 30분 정도의 짧은 수명을 두면 탈취돼도 피해 시간이 제한된다. 응답의 {"access_token": ..., "token_type": "bearer"} 형태도 OAuth2 표준 이라, 클라이언트가 받아서 그대로 쓰기 좋다.
4. 보호된 라우트 — 토큰이 있어야 들어온다
이제 마지막 조각. 발급한 토큰을 매 요청에서 검사해서, 유효한 토큰을 가진 사람만 통과시키는 라우트를 만든다. 여기서 5편에서 배운 Depends 가 빛난다. "현재 사용자를 알아내는" 의존성을 한 번 만들어 두고, 보호가 필요한 모든 라우트에 끼워 넣으면 된다.
OAuth2PasswordBearer(tokenUrl="token") 가 두 가지를 한다 — 요청 헤더의 Authorization: Bearer <토큰> 에서 토큰을 뽑아 주고, /docs 화면에 로그인 버튼을 자동으로 만들어 준다. jwt.decode 는 서명을 검증하고 만료를 확인하는데, 둘 중 하나라도 어긋나면 JWTError 를 던진다 — 그래서 위조 토큰이든 만료 토큰이든 모두 401 로 떨어진다. 이제 보호된 라우트는 한 줄짜리다.
이게 전부다. /me 함수 시그니처에 user = Depends(get_current_user) 한 줄을 끼운 것만으로, FastAPI 는 함수 본문을 실행하기 전에 토큰을 검사한다. 토큰이 없거나 위조됐거나 만료됐으면 함수는 아예 호출되지 않고 곧장 401 이 나간다. 보호가 필요한 다른 라우트도 같은 의존성 한 줄만 붙이면 된다 — 인증 로직을 라우트마다 베껴 쓸 필요가 없다.
응용은 자연스럽게 따라온다. 로그인한 사용자만 자기 글을 수정하게 하려면 get_current_user 가 돌려준 user 와 글 작성자를 비교하면 되고(여기서부터가 인가다), 관리자 전용 라우트는 user.is_admin 을 조건문 하나로 검사하면 된다. 단단한 인증 위에서 인가는 이렇게 가벼워진다.
요약
9편을 한 호흡에 정리하면 — 인증은 "너 누구냐", 인가는 "너 이거 해도 되냐"다. 비밀번호는 절대 평문이나 MD5 로 저장하지 않고 passlib + bcrypt 로 해싱한다. 로그인이 통과하면 OAuth2PasswordRequestForm 으로 폼을 받아 검증하고, python-jose 의 jwt.encode 로 sub·exp 를 담은 JWT 를 환경변수 SECRET_KEY 로 서명해 발급한다. 보호된 라우트는 OAuth2PasswordBearer 로 토큰을 뽑고 get_current_user 의존성에서 jwt.decode 로 검증해, 위조·만료 토큰을 모두 401 로 막는다. 인증의 뼈대가 이 세 단계 — 해싱 · 발급 · 검증 — 안에 다 들어 있다.
다음 편 예고 — FastAPI 테스트
지금까지 만든 API 가 정말 의도대로 동작하는지, TestClient 와 pytest 로 자동 검증하는 법을 배운다.