HTTP 기본 인증
HTTP 기본 인증 (HTTP Basic Auth)
가장 단순한 경우에는 HTTP Basic Auth를 사용할 수 있어요.
HTTP Basic Auth에서 앱은 사용자 이름과 비밀번호를 담은 헤더를 기대합니다.
이 헤더를 받지 못하면 HTTP 401 "Unauthorized" 오류를 돌려줍니다.
그리고 값이 Basic인 WWW-Authenticate 헤더와 선택적인 realm 파라미터를 돌려주죠.
그러면 브라우저가 사용자 이름·비밀번호를 입력하는 통합 프롬프트를 보여줘요.
그리고 그 사용자 이름과 비밀번호를 입력하면, 브라우저가 자동으로 헤더에 담아 보냅니다.
출처: 공식문서
간단한 HTTP Basic Auth
HTTPBasic과HTTPBasicCredentials를 import 하세요.HTTPBasic으로 "security스킴"을 만드세요.- 그
security를 _path operation_의 의존성으로 사용하세요. HTTPBasicCredentials타입의 객체를 돌려줍니다:- 거기에는 보내진
username과password가 담겨 있어요.
- 거기에는 보내진
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.security import HTTPBasic, HTTPBasicCredentials
app = FastAPI()
security = HTTPBasic()
@app.get("/users/me")
def read_current_user(credentials: Annotated[HTTPBasicCredentials, Depends(security)]):
return {"username": credentials.username, "password": credentials.password}
🤓 다른 버전과 변형
팁: 가능하면
Annotated버전을 사용하세요.from fastapi import Depends, FastAPI from fastapi.security import HTTPBasic, HTTPBasicCredentials app = FastAPI() security = HTTPBasic() @app.get("/users/me") def read_current_user(credentials: HTTPBasicCredentials = Depends(security)): return {"username": credentials.username, "password": credentials.password}
처음으로 URL을 열려고 하면(또는 문서에서 "Execute" 버튼을 누르면) 브라우저가 사용자 이름과 비밀번호를 물어봅니다.
사용자 이름 확인하기
좀 더 완성된 예시를 볼게요.
의존성으로 사용자 이름과 비밀번호가 올바른지 확인해 봐요.
이를 위해 파이썬 표준 모듈 secrets로 사용자 이름과 비밀번호를 확인합니다.
secrets.compare_digest()는 bytes 또는 ASCII 문자(영문)만 담긴 str을 받아야 해요. 즉 Sebastián의 á 같은 문자에서는 동작하지 않아요.
그걸 처리하기 위해 먼저 username과 password를 UTF-8로 인코딩해 bytes로 바꿉니다.
그다음 secrets.compare_digest()로 credentials.username이 "stanleyjobson"이고 credentials.password가 "swordfish"인지 확인할 수 있어요.
import secrets
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
app = FastAPI()
security = HTTPBasic()
def get_current_username(
credentials: Annotated[HTTPBasicCredentials, Depends(security)],
):
current_username_bytes = credentials.username.encode("utf8")
correct_username_bytes = b"stanleyjobson"
is_correct_username = secrets.compare_digest(
current_username_bytes, correct_username_bytes
)
current_password_bytes = credentials.password.encode("utf8")
correct_password_bytes = b"swordfish"
is_correct_password = secrets.compare_digest(
current_password_bytes, correct_password_bytes
)
if not (is_correct_username and is_correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
@app.get("/users/me")
def read_current_user(username: Annotated[str, Depends(get_current_username)]):
return {"username": username}
🤓 다른 버전과 변형
팁: 가능하면
Annotated버전을 사용하세요.import secrets from fastapi import Depends, FastAPI, HTTPException, status from fastapi.security import HTTPBasic, HTTPBasicCredentials app = FastAPI() security = HTTPBasic() def get_current_username(credentials: HTTPBasicCredentials = Depends(security)): current_username_bytes = credentials.username.encode("utf8") correct_username_bytes = b"stanleyjobson" is_correct_username = secrets.compare_digest( current_username_bytes, correct_username_bytes ) current_password_bytes = credentials.password.encode("utf8") correct_password_bytes = b"swordfish" is_correct_password = secrets.compare_digest( current_password_bytes, correct_password_bytes ) if not (is_correct_username and is_correct_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Basic"}, ) return credentials.username @app.get("/users/me") def read_current_user(username: str = Depends(get_current_username)): return {"username": username}
이것은 이렇게 쓰는 것과 비슷해요:
if not (credentials.username == "stanleyjobson") or not (credentials.password == "swordfish"):
# Return some error
...
하지만 secrets.compare_digest()를 쓰면 "타이밍 공격(timing attacks)"이라는 공격 유형에 안전해집니다.
타이밍 공격 (Timing Attacks)
그럼 "타이밍 공격"이 뭘까요?
공격자들이 사용자 이름과 비밀번호를 추측하려 한다고 상상해 봐요.
그들이 사용자 이름 johndoe, 비밀번호 love123로 요청을 보냈다고 해요.
그러면 여러분 앱의 파이썬 코드는 이런 작업과 동일할 거예요:
if "johndoe" == "stanleyjobson" and "love123" == "swordfish":
...
그런데 파이썬이 johndoe의 첫 글자 j를 stanleyjobson의 첫 글자 s와 비교하는 순간 False를 돌려줘요. 이미 두 문자열이 같지 않다는 걸 알기 때문에, "나머지 글자를 비교하는 데 더 계산을 낭비할 필요도 없네"라고 생각하는 거죠. 그러면 여러분 앱은 "Incorrect username or password"라고 답합니다.
하지만 공격자들은 이번엔 사용자 이름 stanleyjobsox, 비밀번호 love123으로 시도해 봐요.
그러면 여러분 앱 코드는 이렇게 됩니다:
if "stanleyjobsox" == "stanleyjobson" and "love123" == "swordfish":
...
파이썬은 stanleyjobsox와 stanleyjobson 둘 다에서 stanleyjobso 전체를 비교하고 나서야 두 문자열이 같지 않다는 걸 알아차려요. 그래서 "Incorrect username or password"라고 답하는 데 몇 마이크로초가 더 걸립니다.
응답 시간이 공격자를 도와주는 이유
그 시점에, 서버가 "Incorrect username or password" 답변을 보내는 데 몇 마이크로초 더 걸린다는 걸 알아차린 공격자들은, 뭔가 올바르게 맞췄다는 것, 즉 처음 몇 글자가 맞았다는 걸 알게 됩니다.
그리고 이번엔 johndoe보다 stanleyjobsox에 가깝다는 걸 알고 다시 시도할 수 있죠.
"전문적인" 공격
물론 공격자들은 이런 걸 손으로 하지 않아요. 매초 수천·수백만 번의 테스트를 하는 프로그램을 작성할 거예요. 그리고 한 번에 올바른 글자 하나씩만 더 얻게 됩니다.
하지만 그렇게 하면 몇 분·몇 시간 안에, 우리 앱의 "도움"을 받아, 단지 응답에 걸린 시간만으로 올바른 사용자 이름과 비밀번호를 알아낼 수 있어요.
secrets.compare_digest()로 고치기
하지만 우리 코드는 실제로 secrets.compare_digest()를 사용하고 있어요.
요컨대, stanleyjobsox를 stanleyjobson과 비교하는 시간과 johndoe를 stanleyjobson과 비교하는 시간이 같아집니다. 비밀번호도 마찬가지고요.
그렇게 secrets.compare_digest()를 앱 코드에 쓰면, 이런 보안 공격 범위 전체로부터 안전해집니다.
오류 돌려주기
자격 증명이 틀렸다는 걸 감지한 뒤에는 상태 코드 401(자격 증명이 없을 때 돌려주는 것과 동일)의 HTTPException을 돌려주고, 브라우저가 로그인 프롬프트를 다시 보여주도록 WWW-Authenticate 헤더를 추가하면 됩니다:
import secrets
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
app = FastAPI()
security = HTTPBasic()
def get_current_username(
credentials: Annotated[HTTPBasicCredentials, Depends(security)],
):
current_username_bytes = credentials.username.encode("utf8")
correct_username_bytes = b"stanleyjobson"
is_correct_username = secrets.compare_digest(
current_username_bytes, correct_username_bytes
)
current_password_bytes = credentials.password.encode("utf8")
correct_password_bytes = b"swordfish"
is_correct_password = secrets.compare_digest(
current_password_bytes, correct_password_bytes
)
if not (is_correct_username and is_correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
@app.get("/users/me")
def read_current_user(username: Annotated[str, Depends(get_current_username)]):
return {"username": username}
🤓 다른 버전과 변형
팁: 가능하면
Annotated버전을 사용하세요.import secrets from fastapi import Depends, FastAPI, HTTPException, status from fastapi.security import HTTPBasic, HTTPBasicCredentials app = FastAPI() security = HTTPBasic() def get_current_username(credentials: HTTPBasicCredentials = Depends(security)): current_username_bytes = credentials.username.encode("utf8") correct_username_bytes = b"stanleyjobson" is_correct_username = secrets.compare_digest( current_username_bytes, correct_username_bytes ) current_password_bytes = credentials.password.encode("utf8") correct_password_bytes = b"swordfish" is_correct_password = secrets.compare_digest( current_password_bytes, correct_password_bytes ) if not (is_correct_username and is_correct_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Basic"}, ) return credentials.username @app.get("/users/me") def read_current_user(username: str = Depends(get_current_username)): return {"username": username}