[Python]Fast APIでTodoリストに認証機能をつけてみた

## 概要

Zennに投稿した下記記事で作ったTodoリストのアプリに認証機能をつけてみました(具体的には新規登録・ログイン)
[[Python]Fast APIでTodoリストをつくってみた](https://zenn.dev/naoya_kuma/articles/788e83666a4034)

https://github.com/momonoki1990/fastapi-todo-list-api

※上記記事ではフロントエンドも作成しましたが、本対応に追従していません😢

## 方法

FastAPI公式チュートリアルの[Security](https://fastapi.tiangolo.com/tutorial/security/)という章を参考にしました。
※日本語訳で参照すると、Securityの章で参照できるページが5→2に減ってしまうので、英語でご覧いただく方がオススメです。


### OAuth2PasswordBearer

参考: [FastAPI's OAuth2PasswordBearer](https://fastapi.tiangolo.com/tutorial/security/first-steps/#fastapis-oauth2passwordbearer)

```py
from fastapi import Depends, FastAPI
from fastapi.security import OAuth2PasswordBearer

app = FastAPI()

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

@app.get("/items/")
async def read_items(token: str = Depends(oauth2_scheme)):
    return {"token": token}
```

`OAuth2PasswordBearer`というクラスを使って、`oauth2_scheme`を生成し、`Depends()`に渡します。
こうすることで、例えば上記コードの場合は、`/items`にGETでアクセスする際に`oauth2_schem()`が実行され、認証情報が要求されるようになります。
具体的には、リクエストヘッダーの`Authorization`ヘッダーに`Bearer`+文字列（トークン）が存在するかどうかをチェックし、トークンを返します。
また、`Authorization`ヘッダーがなかったり、`Bearer`文字列がない場合は、`401 Unauthorized Error`レスポンスを返します。

さらに、自動で生成されるOpenAPIドキュメントには、「Authorize」というボタンが表示されます。
認証情報が入力できるようになり、`oauth2_schema`を`Depends`に持つ、認証で保護されているリソースにアクセスできるようになります。

![スクリーンショット 2022-01-05 6.53.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/555201/56b52971-e186-31f9-19f2-df858089a2c1.png)

![スクリーンショット 2022-01-05 6.53.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/555201/d5f191b1-412e-9886-32f8-10d70d0e46fb.png)



リクエストにおける認証情報の要求とトークンの取得、ドキュメントでの認証機能を追加してくれるというわけですね。

ちなみに、今回作ったTodoリストアプリでは、下記のように、`@router.get("/tasks")`リソースの依存関数`get_current_active_user`の依存関数→`get_current_user`の中で、依存関数として`oauth2_schema`を渡しています。
(取得したtokenを元にDBに問い合わせて、有効なユーザー情報を取得しています)

```py:/src/routers/task.py
@router.get("", response_model=List[task_schema.Task])
async def list_tasks(
    current_user: user_schema.User = Depends(authenticate.get_current_active_user),
    db: AsyncSession = Depends(get_db)
):
```

```py:/src/libs/authenticate.py

async def get_current_user(
    token: str = Depends(oauth2_schema),
    db: AsyncSession = Depends(get_db)
):
...

async def get_current_active_user(current_user: user_schema.User = Depends(get_current_user)):
...
```

### OAuth2PasswordRequestForm

参考: [Code to get the username and password](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/#code-to-get-the-username-and-password)

```py
@app.post("/token")
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
```

次に、認証情報を受け付けてログインできるようにするために`OAuth2PasswordRequestForm`をパスオペレーション関数(ここでは`async def login`)の引数の型ヒントに設定します。

こうすることで、リクエストボディに`username` `password`が要求されるようになります。
また、オプションとして`scope`と`grant_type`も受け付けることができるようになっています(OAuth2では`grant_type`が必須らしい)

今回は、下記のように、ログイン時に認証(` authenticate.authenticate_user`)して入力された`username`と`password`(DBではハッシュ値)に一致するユーザーを取得、`username`を元にトークンを生成し、レスポンスボディとして返しています。

```py:src/routers/user.py
@router.post("/token", response_model=user_schema.Token)
async def login_for_access_token(
    form_data: OAuth2PasswordRequestForm = Depends(),
    db: AsyncSession = Depends(get_db)
):
    user = await authenticate.authenticate_user(db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"}
        )
    access_token = authenticate.create_access_token(user.username)
    return {"access_token": access_token, "token_type": "bearer"}
```

```py:src/libs/authenticate.py
async def authenticate_user(db: AsyncSession, username: str, password: str):
    user = await user_crud.get_user(db, username)
    if not user:
        return False
    if not verify_password(password, user.password):
        return False
    return user

def create_access_token(username: str):
    expire = datetime.now() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    payload = {
        "sub": username,
        "exp": expire
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
```

### トークン

トークンはJWT(ジョット)トークンを使用しています。
JWTトークンに関しては、[【JWT】 入門](https://qiita.com/Naoto9282/items/8427918564400968bd2b)が詳しいです。

>JWTとは
...
JSON Web Tokenの略　　
電子署名により、改ざん検知できる。
認証用のトークンなどで用いられる。

>構成
ヘッダ、ペイロード、署名の3つから成る。
それぞれは、Base64でエンコードされている
それぞれは、 . (ドット) で結合されている。

ペイロードには任意の情報を含めることができます。
今回の場合は`username`を含めており、トークンをデコードして`username`を取得、DBに問い合わせてユーザー情報を取得しています。

```py:authenticate.py
async def get_current_user(
    token: str = Depends(oauth2_schema),
    db: AsyncSession = Depends(get_db)
):
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"}
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
        token_data = user_schema.TokenData(username=username)
    except JWTError:
        raise credentials_exception
    user = await user_crud.get_user(db, username=token_data.username)
    if user is None:
        raise credentials_exception
    return user
```

### その他

- パスワードのハッシュ化には、`Passlib`というライブラリを使用しています(アルゴリズムは`bcrypt`)。
  <br>参考: [Install passlib](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/#install-passlib)　[Passlib](https://passlib.readthedocs.io/en/stable/)
- JWTの生成・デコードには、`python-jose`というライブラリを使用しています。`Cryptographic Backends`というものを指定する必要があるらしく、今回はFastAPIチュートリアルに従って`cryptography`を選択しています。
  <br>参考: [Install python-jose](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/#install-python-jose)　[python-jose](https://github.com/mpdavis/python-jose)
- 私の理解が曖昧なのですっ飛ばしましたが、今回の認証の流れはOAuth2の「パスワード」フローを採用しているようで、概要がチュートリアルの最初で説明されています
  <br>参考: [The password flow](https://fastapi.tiangolo.com/tutorial/security/first-steps/#the-password-flow)
  <br>([OAuth 2.0 全フローの図解と動画](https://qiita.com/TakahikoKawasaki/items/200951e5b5929f840a1f)だとどれに該当するんだろう...？)
- 今回は`username`を認証情報としていますが、代わりに`email`を必須にしたかったらどうすればいいんだろう（それってOAuth2のフローに準拠していないことになるのかな、どうなんだろう...?）