こんにちは、わいけい です。
突然ですが、私は日々FastAPIを使った開発をしているのですが認証周りで時々面倒を感じることがあります。
それは、せっかくFastAPIがOpenAPI形式の動的ドキュメント(Swagger UI)を自動生成してくれているのにも関わらず、ログインが必要なエンドポイントに関しては毎度認証トークンを取得しないと叩くことが出来ないということです。
セキュリティ上「まあ、それはそうだよね」という話ではあります。
が、ローカルでコンテナ立てて開発しているときには地味に面倒なのもまた事実です。
長期的に開発を続ける想定であれば、開発チーム全体で何千回もローカル環境でのログイン&トークン取得を行うことになることが予想されます。
これは開発効率の観点からあまり好ましくなさそうだと私は思いました。
そこで今回は開発環境でのみ良い感じに認証をスキップする実装を行ってみました。
私のSNSアカウント等です。
今後もPython・LLM・Go・Web開発などのトピックについて発信していくのでフォローしていただけると喜びます。
X: わいけい
LinkedIn: ykimura517
Github: ykimura517
やりたいこと
具体的には以下を実現するのを目標とします。
- 本番環境ではリクエストのヘッダーに付与されたjwtトークンを真面目に検証し、通常の認証機能を実現する。同時に、アクセスしているユーザーのidもこの時取得するものとする。
- 開発環境(主にローカルを想定)ではリクエストにトークンが乗っていなくてもそれぞれの開発者が指定したユーザーIDで自動的に認証が通るようにする。
- これらの設定はいつでも切り替えられるようにする
大前提として、アプリケーションで使用されているuser_id
がjwtトークンにエンコードされて送られてきているシチュエーションを想定しています。
(認証の仕方も色々あると思いますが、ここではjwtトークンによる認証方法を想定しました。
なのでAWS Cognitoとかで認証を行っている場合でも今回の方法が応用できると思います。)
まず普通にFastAPIを起動してみる
まず、普通にDocker環境下でローカルにてFastAPIアプリケーションを起動してみます。
以下のファイル群を準備してください。
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
fastapi==0.105.0
uvicorn==0.23.1
python-jose==3.3.0
version: '3.8'
services:
web:
build: .
ports:
- "8000:8000"
volumes:
- .:/app
command: uvicorn main:app --reload --host 0.0.0.0 --port 8000
from fastapi import FastAPI, Depends, HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
# JWTトークンの秘密鍵とアルゴリズム
SECRET_KEY = "your_secret_key"
ALGORITHM = "HS256"
app = FastAPI()
security = HTTPBearer()
def get_current_user(token: HTTPAuthorizationCredentials = Security(security)):
try:
payload = jwt.decode(token.credentials, SECRET_KEY, algorithms=[ALGORITHM])
user_id = payload.get("user_id")
if user_id is None:
raise HTTPException(status_code=403, detail="Invalid authentication credentials")
return user_id
except JWTError:
raise HTTPException(status_code=403, detail="Invalid token or expired token")
@app.get("/protected")
def read_protected(user_id: str = Depends(get_current_user)):
return {"user_id": user_id}
@app.get("/open")
def read_open():
return {"response":"Hello!"}
この状態でdocker compose up
してコンテナのビルドを行います。
その後、ブラウザでlocalhost:8000/docs
にアクセスしてSwaggerUIが見られれば成功です。
下の画像のように、
/protected
と/open
の2つのエンドポイントが存在していると思います。
この内、/protected
の方は鍵マークがついていることからも察せられる通り、認証を要求する設定になっています。
実際にそのまま叩いてみるとステータスコード403で
{
"detail": "Not authenticated"
}
というレスポンスが来ると思います。
(一方/open
の方は普通に叩けますね)
この/protected
エンドポイントを叩くには、鍵マークをクリックして例えば下記のトークンをセットしてあげる必要があります。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiZXhhbXBsZV91c2VyX2lkIn0.ciWMfJIkODOHxqyAe3rmEFxphZfjVgbnDhR7bop05Lg
(これは下記コードにて私が作成した仮のトークンです。サンプルなので有効期限などが設定されていないことに注意してください。)
from jose import jwt
SECRET_KEY = "your_secret_key"
ALGORITHM = "HS256"
user_id = "example_user_id"
token = jwt.encode({"user_id": user_id}, SECRET_KEY, algorithm=ALGORITHM)
print(token)
実際に上記トークンがヘッダーに乗った状態だと、問題なく/protected
が叩けたかと思います。
このようなFastAPIの認証サポート機能は非常に便利かつ、これ自体かなり開発者フレンドリーでもあります。
が、冒頭でも述べたとおり、私にはローカル開発時に毎回トークン入力するのは面倒に感じられました(かなりの面倒くさがりなのです)。
ローカル開発環境での認証スキップ方法
そこで、ローカル開発時は任意で認証をスキップしつつ、好きなuser_idでログインしたように見せかけるようにして行きたいと思います。
まず、下記の.env
ファイルを作成します。
AUTH_SKIP=true
AUTH_SKIP_USER_ID=my-user-id
あわせてdocker-compose.yml
を以下のように変更します。
version: '3.8'
services:
web:
build: .
ports:
- "8000:8000"
volumes:
- .:/app
command: uvicorn main:app --reload --host 0.0.0.0 --port 8000
environment:
- AUTH_SKIP=${AUTH_SKIP}
- AUTH_SKIP_USER_ID=${AUTH_SKIP_USER_ID}
この状態でサーバーを起動すると、.env
に記載された環境変数が取得できるようになります。
次は環境変数に応じて、認証処理をカスタマイズしていきます。
以下のように、main.py
を変更します。
from fastapi import FastAPI, Depends, HTTPException, Security, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
import os
# 環境設定
SECRET_KEY = "your_secret_key"
ALGORITHM = "HS256"
app = FastAPI()
security = HTTPBearer()
def get_current_user(token: HTTPAuthorizationCredentials = Security(security)) -> str:
# 開発モードでは認証をスキップし、いつでも環境変数で指定したuser_idを使う
AUTH_SKIP = os.getenv("AUTH_SKIP", "false") == "true" #pythonではbool("false")がTrueとなることなどに注意する
if AUTH_SKIP:
user_id = os.getenv("AUTH_SKIP_USER_ID")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Please set AUTH_SKIP_USER_ID in .env file.",
)
return user_id
try:
payload = jwt.decode(token.credentials, SECRET_KEY, algorithms=[ALGORITHM])
user_id: str = payload.get("user_id")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid authentication credentials",
)
return user_id
except JWTError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid token or expired token",
)
@app.get("/protected")
def read_protected(user_id: str = Depends(get_current_user)):
return {"user_id": user_id}
@app.get("/open")
def read_open():
return {"response": "Hello!"}
上記では、get_current_user
関数内で環境変数AUTH_SKIP
をチェックし、値によっては認証処理自体をスキップする実装にしています。
この状態で改めてdocker compose up --build
し、ブラウザでlocalhost:8000/docs
にアクセスしてトークンをセットせずに/protected
エンドポイントを叩いてみましょう。
無事認証を通過……できませんね。
先程と同じく403で、
{
"detail": "Not authenticated"
}
が返ってきたかと思います。
FastAPIのHTTPBearerの設定を変更
改めてFastAPIのソースコードを読んでみます。
すると、この原因は独自に作成したSkip処理に入る前にFastAPI側でエラーになっていることだと分かります。
具体的にはHTTPBearer
クラスの__call__
メソッドの中でAuthorization
ヘッダーが空の場合に強制エラーにされてしまう仕様が原因のようです。
(つまり逆に言うと現在の状態でもAPIを叩くときに適当な文字列(aaaとか)をヘッダーに入れておくと認証を突破出来ます。)
これでも正規のトークンを毎回入れるよりは大分ラクなのですが、とはいえ毎回適当な文字列を入れるのも面倒かつ不自然です。
更にFastAPIのコードを読むと、HTTPBearer
クラスの__init__
でauto_error
パラメータを調節することでこの事象を回避できそうだと分かります。
auto_error: Annotated[
bool,
Doc(
"""
By default, if the HTTP Bearer token not provided (in an
`Authorization` header), `HTTPBearer` will automatically cancel the
request and send the client an error.
If `auto_error` is set to `False`, when the HTTP Bearer token
is not available, instead of erroring out, the dependency result will
be `None`.
This is useful when you want to have optional authentication.
It is also useful when you want to have authentication that can be
provided in one of multiple optional ways (for example, in an HTTP
Bearer token or in a cookie).
"""
),
] = True,
ということで開発環境では、これがFalse
になるようにしておきます。
from fastapi import FastAPI, Depends, HTTPException, Security, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
import os
# 環境設定
SECRET_KEY = "your_secret_key"
ALGORITHM = "HS256"
app = FastAPI()
AUTH_SKIP = os.getenv("AUTH_SKIP", "false") == "true"
security = HTTPBearer(auto_error=not AUTH_SKIP)
def get_current_user(token: HTTPAuthorizationCredentials = Security(security)) -> str:
if AUTH_SKIP:
user_id = os.getenv("AUTH_SKIP_USER_ID")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Please set AUTH_SKIP_USER_ID in .env file.",
)
return user_id
try:
payload = jwt.decode(token.credentials, SECRET_KEY, algorithms=[ALGORITHM])
user_id: str = payload.get("user_id")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid authentication credentials",
)
return user_id
except JWTError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid token or expired token",
)
@app.get("/protected")
def read_protected(user_id: str = Depends(get_current_user)):
return {"user_id": user_id}
@app.get("/open")
def read_open():
return {"response": "Hello!"}
これでAuthorizationヘッダーがnullでも認証を通せるようになりました!お疲れ様でした。
注意点
言うまでもなく、本番環境で認証が外れていると大変なことになります。
なので、本番では設定ミスで認証が外れないようにくれぐれも気をつけてください。
また、開発環境でも状況によっては普通にログイン&認証したい場合も多々あると思います。
その場合は.env
のAUTH_SKIP
をいじった上でdockerを起動させればOKです。
私のSNSアカウント等です。
今後もPython・LLM・Go・Web開発などのトピックについて発信していくのでフォローしていただけると喜びます。
X: わいけい
LinkedIn: ykimura517
Github: ykimura517