패스워드와 Bearer를 쓰는 간단한 OAuth2
패스워드와 Bearer를 쓰는 간단한 OAuth2 (Simple OAuth2 with Password and Bearer)
직전 장에서 "현재 사용자"를 얻는 방법을 배웠어요. 이제 거기에 빠져 있던 조각들을 채워서, 완성된 보안 흐름을 만들어 볼게요. 여기서는 아직 실제로 안전하진 않아요. "보안 시스템이 도는 구조"를 먼저 잡는 게 목표고, 진짜 보안(패스워드 해싱과 JWT)은 다음 장에서 다뤄요.
출처: 공식문서
username과 password 받기
FastAPI의 보안 유틸리티를 사용해 username과 password를 받을 거예요.
여기서 짚고 갈 OAuth2 규칙이 하나 있어요. 우리가 쓰는 "password flow"에서는 클라이언트/사용자가 username과 password 필드를 폼 데이터로 보내야 해요. 그리고 필드 이름이 반드시 username, password여야 해요. user-name이나 email 같은 이름은 스펙과 안 맞아서 안 돼요.
물론 프론트엔드에서 사용자에게 보이는 라벨을 어떻게 보여줄지는 자유이고, 데이터베이스 모델의 컬럼 이름도 마음대로 정할 수 있어요. 다만 **로그인 경로 동작**에서 보내는 이름만은 스펙과 맞춰야 해요. 그래야 스펙 호환(예: 내장 API 문서 시스템 사용)이 가능하니까요. 그리고 username/password는 폼 데이터(즉 JSON이 아님)로 보내야 한다는 점도 기억해 두세요.
scope
OAuth2 스펙에 따르면 클라이언트는 또 하나의 폼 필드인 scope를 보낼 수 있어요. 폼 필드 이름은 단수인 scope지만, 실제 내용은 공백으로 구분된 여러 "스코프"가 들어있는 긴 문자열이에요. 각 "스코프"는 공백 없는 문자열 하나이고, 보통 특정 보안 권한을 선언하는 데 써요.
실제 예시를 보면 감이 와요.
users:read,users:write같은 형태가 흔해요.instagram_basic은 Facebook/Instagram에서 써요.https://www.googleapis.com/auth/drive는 Google에서 써요.
참고
OAuth2에서 "스코프(scope)"는 그냥 특정 권한을 나타내는 문자열일 뿐이에요.
:같은 문자가 들어갔는지, URL처럼 생겼는지는 중요하지 않아요. 그런 세부 사항은 구현마다 다르고, OAuth2 입장에서는 그냥 문자열이면 그만이에요.
username과 password 받는 코드
OAuth2PasswordRequestForm
먼저 OAuth2PasswordRequestForm을 import 하고, /token _경로 동작_에서 Depends로 의존성처럼 사용해요.
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
fake_users_db = {
"johndoe": {
"username": "johndoe",
"full_name": "John Doe",
"email": "[email protected]",
"hashed_password": "fakehashedsecret",
"disabled": False,
},
"alice": {
"username": "alice",
"full_name": "Alice Wonderson",
"email": "[email protected]",
"hashed_password": "fakehashedsecret2",
"disabled": True,
},
}
app = FastAPI()
def fake_hash_password(password: str):
return "fakehashed" + password
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
class User(BaseModel):
username: str
email: str | None = None
full_name: str | None = None
disabled: bool | None = None
class UserInDB(User):
hashed_password: str
def get_user(db, username: str):
if username in db:
user_dict = db[username]
return UserInDB(**user_dict)
def fake_decode_token(token):
# This doesn't provide any security at all
# Check the next version
user = get_user(fake_users_db, token)
return user
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
user = fake_decode_token(token)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return user
async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)],
):
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
@app.post("/token")
async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
user_dict = fake_users_db.get(form_data.username)
if not user_dict:
raise HTTPException(status_code=400, detail="Incorrect username or password")
user = UserInDB(**user_dict)
hashed_password = fake_hash_password(form_data.password)
if not hashed_password == user.hashed_password:
raise HTTPException(status_code=400, detail="Incorrect username or password")
return {"access_token": user.username, "token_type": "bearer"}
@app.get("/users/me")
async def read_users_me(
current_user: Annotated[User, Depends(get_current_active_user)],
):
return current_user
OAuth2PasswordRequestForm은 요청 폼 본문에 아래 필드들이 들어있다고 선언하는 클래스 의존성이에요.
usernamepassword- 선택적인
scope(공백으로 구분된 문자열들로 이뤄진 긴 문자열) - 선택적인
grant_type
팁
OAuth2 스펙은 사실
grant_type필드가 고정값password로 올 것을 요구해요. 그런데OAuth2PasswordRequestForm은 이걸 강제하지 않아요. 강제해야 한다면OAuth2PasswordRequestFormStrict를 쓰면 돼요.
- 선택적인
client_id(이 예제에선 안 써요) - 선택적인
client_secret(이 예제에선 안 써요)
참고
OAuth2PasswordRequestForm은OAuth2PasswordBearer처럼 FastAPI가 특별 취급하는 클래스가 아니에요.OAuth2PasswordBearer는 FastAPI가 "이게 보안 스킴이다"라고 알아채서 OpenAPI에 그렇게 추가해요. 반면OAuth2PasswordRequestForm은 여느 클래스 의존성과 같아서, 사실 직접 만들어도 되고Form파라미터를 그냥 선언해도 됐을 거예요. 다만 이게 워낙 흔한 패턴이라 FastAPI가 미리 제공해 주는 것뿐이에요.
OAuth2PasswordRequestForm의 인스턴스에는 scope라는 긴 문자열 속성이 아니라, 각 스코프가 잘라진 scopes라는 리스트 속성이 들어있어요. 이 예제에서는 scopes를 쓰지 않지만, 필요하면 그 기능이 그대로 있어요.
폼 데이터 사용하기
이제 폼의 username으로 (가짜) 데이터베이스에서 사용자 데이터를 가져와요. 해당 사용자가 없으면 "Incorrect username or password" 오류를 돌려줘요. 이 오류는 HTTPException을 던져서 만들어요.
@app.post("/token")
async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
user_dict = fake_users_db.get(form_data.username)
if not user_dict:
raise HTTPException(status_code=400, detail="Incorrect username or password")
user = UserInDB(**user_dict)
hashed_password = fake_hash_password(form_data.password)
if not hashed_password == user.hashed_password:
raise HTTPException(status_code=400, detail="Incorrect username or password")
return {"access_token": user.username, "token_type": "bearer"}
패스워드 확인하기
여기까지 하면 사용자 데이터는 얻었지만, 패스워드는 아직 확인 안 했어요. 데이터를 Pydantic UserInDB 모델에 넣고, (가짜) 패스워드 해싱 시스템을 이용해 확인해요. 평문 패스워드를 저장하면 안 되기 때문에 항상 해싱을 거쳐야 해요. 패스워드가 일치하지 않으면 같은 오류를 돌려주죠.
패스워드 해싱이란
해싱은 어떤 내용(여기선 패스워드)을 알아볼 수 없는 바이트 시퀀스(그냥 문자열처럼 보이는 것)로 바꾸는 작업이에요. 똑같은 내용(똑같은 패스워드)을 넣으면 똑같은 결과가 나오지만, 반대로 그 결과에서 원래 패스워드를 되돌릴 수는 없어요.
왜 해싱을 쓸까
만약 데이터베이스가 유출된다면, 도둑은 사용자들의 평문 패스워드가 아니라 해시만 갖게 돼요. 그래서 도둑이 그 패스워드를 다른 시스템에서 시도해 보는 것도 막을 수 있어요. 많은 사람이 여러 곳에 같은 패스워드를 쓰기 때문에, 평문이 그대로 새는 건 정말 위험하거든요.
실제로 흘러가는 흐름
로그인이 성공하면 /token이 access_token과 token_type을 돌려줘요. 이 예제에서는 access_token이 그 사용자의 username일 뿐이에요(아직 진짜 보안이 아니라는 뜻이죠). 그 안에서 실제 보안 처리 위치도 확인해 볼게요.
get_current_user는 토큰으로 사용자를 찾고, 없으면401(Not authenticated)을 돌려줘요.WWW-Authenticate: Bearer헤더까지 넣어서 브라우저 다이얼로그가 뜨도록 해요.get_current_active_user는 그 사용자가disabled인지 확인해서, 비활성 사용자면400(Inactive user)을 돌려줘요.
실제로 동작을 확인해 보고 싶다면, 문서 UI에서 Authorize로 로그인한 뒤 /users/me를 호출하면 현재 사용자 정보를 받아볼 수 있어요. 로그아웃하고 다시 시도하면 {"detail": "Not authenticated"}라는 401 응답을 받게 되고요.
비활성 사용자로 시험해 보려면 사용자 alice(패스워드 secret2)로 로그인해 보세요. /users/me를 호출하면 {"detail": "Inactive user"} 오류가 나올 거예요.
요약
이제 username과 password에 기반한 보안 시스템의 뼈대는 완성했어요. 이 도구들은 어떤 데이터베이스, 어떤 사용자/데이터 모델과도 맞출 수 있어요. 다만 아직 진짜로 "안전"하진 않다는 게 유일한 빈틈이에요. 다음 장에서 안전한 패스워드 해싱 라이브러리와 JWT 토큰을 써서 그 빈틈을 메울게요.