Python 초보자를 위한 FastAPI(4) 1
인증과 권한 부여는 어려운 주제이다. 그래서 가능한 한 간단하게 시작하겠다.
FastAPI는 종속성 주입과 보안 시스템을 통해 매우 깔끔하게 인증을 수행할 수 있다. 보호하고자 하는 경로가 보안 시스템에 "의존"하도록 만들기만 하면 된다.
충분히 쉬워 보이지만 사용자를 인증하는 방법을 구현해야 한다. 먼저 users
모듈을 만들어 보겠다.
app
├── __init__.py
├── main.py
├── routers
├── __init__.py
├── tracks.py
├── users.py
dockerfile
docker-compose.yml
requirements.txt
이제 프로젝트 구조는 위와 같다. 그런 다음 tracks.py
와 같은 몇 가지 기본 상용구를 users.py
에 넣겠다...
from fastapi import APIRouter
router = APIRouter()
@router.get("/me")
async def get_current_user():
return {"username": "something..."}
그런 다음 tracks
경로와 마찬가지로 include_router
를 사용하여 앱에 연결한다.
다음으로 인증 전략을 결정해야 한다. 현재 주류 방식은 OAuth2를 사용하는 것이다. 하지만 우리는 예전 방식으로 하려고 한다. 그래서 "기초"이다.
이제 제가 가짜 사용자로 로그인할 수 있는 가짜 사용자가 필요하다.
user = {"username": "john", "password": "password"}
사용자 이름은 John이고, 그는 세상에서 가장 좋은(?) 비밀번호를 갖고 있다. 사용자에게 username
과 password
속성이 필요한 이유는 FastAPI에 내재된HTTPBasicCredentials
이 두 필드를 사용하기 때문이다.
그 코드은 다음과 같다.
class HTTPBasicCredentials(BaseModel):
username: str
password: str
이제 내장 보안 시스템을 도입한다.
from typing import Annotated
from fastapi import APIRouter, Depends
from fastapi.security import HTTPBasic, HTTPBasicCredentials
router = APIRouter()
security = HTTPBasic()
user = {"username": "john", "password": "password"}
@router.get("/me")
async def get_current_user(
credentials: Annotated[HTTPBasicCredentials, Depends(security)]
):
return {"username": "something..."}
먼저 변수 security
을 HTTPBasic
으로 선언한다. 이것은 FastAPI에서 제공하는 내재된 권한 부여 시작 기법이다. 이것이 하는 일은 기본적으로 Authorization
헤더에서 값을 가져와 그 값이 올바른 형식인지 테스트하는 것이다. 그 형식은 Authorization: Basic USERNAME:PASSWORD
이다.
예, 이것이 기본 인증이 작동하는 방식이다. 사용자가 요청을 보낼 때마다 인증 헤더의 값을 데이터베이스에 저장된 사용자와 대조하여 사용자가 실제로 존재하는지, 비밀번호가 정확한지 확인한다. 매우 원시적인 것처럼 들리지만 매우 중요하다.
그런 다음 두 번째로 credential
을 get_current_user
의 인수로 추가한다. 잠깐, 지난번 track
모듈을 작업할 때 함수 내부의 인수가 query 매개변수라고 하지 않았나요?
사실 인자에 요청 헤더를 넣을 수도 있습니다. FastAPI에서는 경로에 요청 헤더를 추가하고 싶을 때 다음과 같은 작업을 수행한다.
def read_items(
my_header: Annotated[str, Header()]
)
인자에 Header()
로 어노테이트면 query 매개변수가 아니라 헤더라는 뜻이다. 그리고 누군가 이 엔드포인트로 요청을 보내려면 요청 헤더에 My-header: SOME_STRING
을 요청 헤더에 추가해야 한다.
그리고 credential
에 Depends(security)
로 어노테이트 하였을 때도 기본적으로 동일한 작업을 수행다. Depends(security)
는 요청 헤더에 Authorization: Basic USERNAME:PASSWORD
를 요구한다.
이제 Swagger UI 페이지로 이동하면 오른쪽 상단에 녹색 "Authorize" 버튼이 있다. 이 버튼을 클릭하면 "Available authorization" 모달이 나타난다. 그런 다음 사용자 아이디와 비밀번호를 사용하여 로그인할 수 있다.
방금 사용자 아이디로 "hello"를, 비밀번호로 "world"를 사용하여 로그인했다고 가정해 보겠다.
그리고 새로 만든 /user/me
경로로 이동하여 새 요청을 보내면 된다. 헤더에 자동으로 권한 부여가 포함된다.
Authorization: Basic aGVsbG86d29ybGQ=
마지막에 나오는 횡설수설은 데이터 전송을 위해 Base64로 인코딩된 hello:world
이다.
최근 대만에서 경찰이 시민의 민감한 정보를 유출했다는 뉴스가 있었다. 그들은 Base64로 인코딩된 시민의 데이터를 누군가에게 넘겨주고 제대로 암호화되었다고 가정했기 때문이다.
이제 인증 부분이 작동한다. 이제 사용자를 인증해야 한다. 인증 헤더 내부의 사용자 이름과 비밀번호가 "john"과 "password"인 경우 비교하기만 하면 되기 때문에 이 부분은 쉬울 것이다. 구현해 보자.
...
from operator import attrgetter
...
async def get_current_user(
credentials: Annotated[HTTPBasicCredentials, Depends(security)]
):
username, password = attrgetter("username", "password")(credentials)
return {"username": username}
여기가 저자가 Python을 별로 좋아하지 않는 부분이다. Javascript에서는 const { username, password } = credential
과 같은 식으로 객체를 파괴할 수 있다. 하지만 Python에서는 이 작업을 수행하는 더 좋은 방법을 찾을 수 없어서 attrgetter
를 사용해야 한다. 그리고 그것이 정말 짧다고 생각하지 않는다.
그래서 credential
에서 username
과 password
를 추출한다. 다음은 가짜 user
와 비교하는 것이다.
@router.get("/me")
async def get_current_user(
credentials: Annotated[HTTPBasicCredentials, Depends(security)]
):
username, password = attrgetter("username", "password")(credentials)
is_correct_user = username == user["username"]
is_correct_password = password == user["password"]
if not is_correct_user and not is_correct_password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
return {"username": username}
권한 부여 헤더에 정확히 동일한 username
이나 password
가 포함되어 있지 않으면 401
unauthorized 예외를 발생시킨다.
하지만 여기에는 많은 잠재적 취약점이 있다. 정부 부처처럼 Base64 인코딩만으로 사용자의 비밀번호를 전송하는 것을 말하는 것이 아니다. HTTPS를 사용하는 한 어느 정도는 허용되기 때문이다. 최고는 아니지만 어느 정도 괜찮다.
가장 먼저 눈에 띄는 점은 비밀번호가 기본적으로 일반 텍스트로 되어 있다는 점이다. 누군가 코드베이스에 액세스할 수 있다면(물론 모든 코드베이스를 GitHub에 푸시하고 비공개 리포지토리가 아니기 때문에 당연히 액세스할 수 있다). 그러나 그것은 또 다른 매우 복잡한 일이다. 그래서 지금 다루지 않겠다.
두 번째는 username
과 password
를 확인하는 방식이다.
프로그램이 두 문자열을 비교하려고 할 때. 기본적으로 우리 인간이 하는 방식과 같다. 문자를 하나씩 비교한다. 그럼 뭐가 문제일까?
비밀번호가 "password"라고 가정하면, 사용자가 "test"와 같은 문자로 로그인을 시도하면. 프로그램은 다음과 같을 것이다.
OK, p
가 t
와 같지? 아니요. 이것은 확실히 잘못된 비밀번호이다. 통과하지 못한다.
하지만 누군가 "passcode"로 로그인을 시도하면. 프로그램이 실행되는 데 시간이 더 오래 걸린다. 왜냐하면 'c'를 읽을 때까지 같은 작업을 다섯 번 반복한 다음 마침내 "passcode"가 정확한 비밀번호가 아니라는 것을 깨닫기 때문이다.
이를 Timing attack이라고 한다. 해커는 트위터 서버의 응답 시간을 이용해 password를 해독할 수 있다. 마치 소싸움을 하는 것처럼, 저희 프로그램은 해커가 얼마나 많은 문자를 추측했는지 알 수 있도록 도와준다.
특히 웹 개발에서는 논쟁의 여지가 있는 문제이긴 하지만, 서버와 클라이언트 사이에는 많은 변수가 존재하기 때문이다. 하지만 이 문제에 조금 더 주의를 기울이는 것은 좋은 습관이다.
이러한 상황을 방지하려면 (이론적으로) 일정한 시간 내에 비교 프로세스를 수행해야 한다. 아마 저만의 순진한 버전을 구현할 수 있을 것이다. 여기에는 다음과 같은 몇 가지 단계가 포함될 수 있다.
- 두 문자열의 길이를 모두 구한다(길이는 문자열 속성이므로 문자열 길이에 따라 해당 속성에 액세스하는 시간이 영향을 받지 않으므로 상수 시간이어야 한다).
- 길이를 비교한다.
- 더 긴 문자열의 길이와 같은 횟수만큼 반복하는 루프를 작성한다.
- 부울 변수를 만든다.
- 루프 안에서 한 글자씩 비교한다.
- 한 문자가 다르면 부울을 거짓으로 표시한다.
- 부울을 반환한다.
개념적으로 일정한 시간이어야 한다. 하지만 누군가 타이밍 공격 기법을 사용하여 비밀번호의 "length"를 알아낼 수 있다. 내 루프의 실행 시간이 노출될 수 있기 때문이다. 따라서 루프도 일정하게 만들어야 한다.
다행히 Python에는 이 목표를 달성하는 데 도움이 될 수 있는 secret
이라는 모듈이 내장되어 있다.
import secrets
...
async def get_current_user(
credentials: Annotated[HTTPBasicCredentials, Depends(security)]
):
username, password = attrgetter("username", "password")(credentials)
is_correct_user = secrets.compare_digest(username, user["username"])
is_correct_password = secrets.compare_digest(password, user["password"])
if not is_correct_user or not is_correct_password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
return {"username": username}
제가 해야 할 일은 secret
모듈을 가져와 compare_digest
라는 메서드를 사용하여 사용자의 입력 값과 저장된 사용자 데이터의 값에 적용하는 것뿐이다. 그게 전부이다!
실제로 어떻게 구현되었는 지는 잘 모르겠다. 하지만 기본적으로 digest
와 compare
라는 두 기능이 있다. digest
부분은 문제인 상수 루프를 만드는 방법을 해결한다. 기본적으로 해시 함수는 항상 동일한 길이를 가진 다이제스트된 값을 반환한다. 그런 다음 두 다이제스트된 값을 비교하기 만하면 된다. 그래도 저와 같은 멍청한 아이디어를 사용하는지 잘 모르겠다.
인증과 권한 부여 흐름은 여기까지이다!
이 구현은 잘 작동하지만...? 분명 개선의 여지가 있겠죠?
모든 소스 코드는 저자의 깃허브에서 찾을 수 있다.
1: 이 페이지는 FastAPI by A Python Beginner (4)을 편역한 것임.