Ccmmutty logo
Commutty IT
0 pv47 min read

公開APIの設計・実装に関するまとめ 〜APIキー管理・認証〜

https://cdn.magicode.io/media/notebox/blob_gSSc8U1
自作のAPIを公開・管理したい場合、例えばAWSのAPI Gatewayなどを使えば自分で1から構築することなく公開APIを整備することができる。
ただし、クラウドサービスが提供するAPI管理サービスには規定のレート制限やタイムアウト上限などがあり、それを超える要件(長い処理時間や高負荷が見込まれるものなど)のAPIを作って公開したい場合はやはり自分で構築していかなければならない。
そのような場合に対応するための、一般利用向けに公開するAPI(システムの内部に閉じたものではない)の設計や認証・認可処理の実装に関する基本的な考え方をまとめる。
なお、各々のシステムの目的・運用に応じて設計思想が異なったり他の手法を採用すべき場合もあるので、これはあくまでも設計の一例であることに留意していただきたい。

使用技術・構成

  • 認証・認可方式:APIキー
  • WEBフレームワーク:FastAPI
  • DB:PostgreSQL
  • ORM:SQLAlchemy
  • データ検証:Pydantic
今回はAPIキーを使って認証・認可を行う前提としているが、他にもOAuth2.0によるアクセストークン(JWTなど)を利用する方式などがある。
また、APIを一般向けに公開する場合、そのAPIの機能説明やリクエスト・レスポンスの仕様を確認できるリファレンスサイトも準備しておくといいが、それについては別記事にまとめる予定。

APIキー認証・認可のイメージ

APIキーはAPI実行時にリクエストヘッダーのX-Api-Keyに設定して渡すことが一般的である。
APIキーを発行したクライアントや利用者を識別(認証)し、APIキーが持つスコープ(≒ロール)がAPIの実行権限に含まれるかチェック(認可)することで、APIへのアクセスを制御できる。
このようにAPIキー方式はAPI実行権限の管理を簡単に行うことができるが、裏を返せば、本来APIの利用が認められていないユーザーであってもキーさえ手に入ってしまえば不正に利用できてしまうという弱さもある。
APIキーを公に流出させてしまったり、開発者がGithubのコミット履歴に載せてしまうといった事故はよくある。
したがって、セキュリティ性を上げるために、キーを無効化(または削除)する機能を付ける、APIキーに一定の有効期限を設ける、他の認証方式と併用するといった対応も場合によって必要になる。
今回はAPIキーの無効化と有効期限を考慮した設計を前提とする。

APIキーの要件

キーの形式

APIキーはランダムで一意な値を生成して使う。
ランダムな値を生成するライブラリといえばuuidやsecretsなどがあるが、APIキーのようにセキュリティに関わるものであれば、より暗号性の高いsecretsが適している。
また、一般的にキーにはsk-(Secret Keyの略)などの特定の接頭辞を付けることが多い。

キーのハッシュ化

発行したAPIキーはシステムのDBに保存しておくことになるが、万一DBのデータが流出でもした場合、生のAPIキーが入っていたら簡単に不正利用され放題になってしまう。
したがって、APIキーをDBに保存する場合は、生のAPIキーのハッシュ値のみを保存することで元の文字列を推測できないようにする設計が望ましい。
ただし、APIキーを後から再表示する要件などがあれば、生キーを暗号化して保管する設計もあり得る。
本記事ではAPIキーは再表示させない(発行時の1回切り)ものとして、ハッシュ化して保存する設計としている。
ハッシュ化でよく使われる関数としてSHA-256、bcrypt、Argon2などがある。
bcrypt、Argon2は、パスワードのような乱雑さが小さく総当たり攻撃などに弱い(人間が覚えやすい・推測されやすい)文字列のハッシュ化に適したものであり、APIキーのように元々乱雑さが十分大きい文字列であればSHA-256で十分という考え方もある。
今回もSHA-256で実装する例にしている。

APIキー管理の実装例

まず今回サンプルとして作ったもののフォルダ構成を示す。
大きく分けると、APIキー管理はapi_key_manage、公開API本体はpublic_api、両方に共通するDB設定やモデル定義はsharedフォルダ配下に実装している。
api_key_manage配下は、FastAPIのmain.pyの他にroutesservicescrudschemasフォルダ(層)という配置になっている。
直接呼ばれるAPIエンドポイントの定義(リクエスト/レスポンス)はRoutes層、Routes層から参照されるPydanticを利用した型定義はSchemas層、SELECTやINSERTなど個々のDB処理はCRUD層、それ以外のビジネスロジック(主にRoutesとCRUDを繋ぐ)などはServices層で行う、というルールで整理しておくと分かりやすい。
ただし、db.commit()についてはDBに関わる処理であるものの、トランザクションの境界であることを考えServices層に書くものとする。
CRUDの関数はDB処理の最小単位とみなし、それらをいくつか束ねて1つのトランザクションとして扱うのはServices層の役割なので、Services側にdb.commit()を書くイメージ。
.
├── Dockerfile
├── alembic
│   ├── README
│   ├── env.py
│   ├── script.py.mako
│   └── versions
├── alembic.ini
├── api_key_manage
│   └── src
│       ├── __init__.py
│       ├── crud
│       │   ├── __init__.py
│       │   └── api_keys.py
│       ├── main.py
│       ├── routes
│       │   ├── __init__.py
│       │   └── api_keys.py
│       ├── schemas
│       │   ├── __init__.py
│       │   └── api_keys.py
│       └── services
│           ├── __init__.py
│           └── api_keys.py
├── compose.yaml
├── public_api
├── requirements.txt
└── shared
    ├── database.py
    └── models.py

管理用DBの定義

モデル定義
発行したAPIキーはDBに登録し、API実行のリクエスト時に付加されたAPIキーとDBのレコードを照合して認証処理などを行う。
APIキー管理用テーブルとして、概ね次のようなカラムを持っていれば最低限使えるものにはなると思う。
物理名論理名
nameキー名称
key_hashキー文字列のハッシュ値
key_prefixキーの先頭数文字
is_activeキーの有効フラグ
scope認可スコープ
expires_at失効日時
last_used_at最終使用日時
created_at作成日時
updated_at更新日時
shared/models.pyに以下のモデルを定義している。
from datetime import datetime
from enum import Enum
from sqlalchemy import Boolean, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from shared.database import Base

class SCOPE_VALUE(str, Enum):
    READ_ONLY = "read_only"  # 読み専用
    READ_WRITE = "read_write"  # 読み書き可能

    @classmethod
    def to_list(cls) -> list[str]:
        return [s.value for s in cls]


class ApiKey(Base):
    __tablename__ = "api_keys"

    id: Mapped[int] = mapped_column(
        Integer, primary_key=True, autoincrement=True)  # APIキーの一意なID
    name: Mapped[str] = mapped_column(String(100), nullable=False)  # APIキー名称
    key_hash: Mapped[str] = mapped_column(
        String(64), nullable=False, unique=True, index=True)  # ハッシュ化したAPIキー文字列
    key_prefix: Mapped[str] = mapped_column(
        String(10), nullable=False, index=True)  # APIキーの先頭数文字
    is_active: Mapped[bool] = mapped_column(
        Boolean, nullable=False, default=True)  # APIキーの有効フラグ
    scope: Mapped[str] = mapped_column(
        String(64), nullable=False, server_default=SCOPE_VALUE.READ_ONLY)  # 認可スコープ
    expires_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=True
    )  # 失効日時
    last_used_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=True
    )  # 最終使用日時
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=True, server_default=func.now()
    )  # 作成日時
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=True, server_default=func.now()
    )  # 更新日時
nameはAPIキーの区別がしやすいように設定する名称で、管理者が任意で設定する。
ユニーク制約を付ける場合もある。
key_prefixは生のキーの先頭から数文字を格納するもので、管理するときの分かりやすさのために入れているが必須ではない。
例えば、sk-abcdefg123456xxyyzzsk-abcdだけ登録しておき、画面上でsk-abcd…のように見せるときに使う。
scopeはAPIの認可(そのAPIキーで対象のAPIを実行できるか)処理に使う文字列で、例えばread_only(読み取り専用)であればGETのAPIのみ、read_write(読み書き可能)であればGETを含む全てのAPIを実行できる、というようにスコープの文字列でアクセス範囲を設定できる。
Enumで定義した型を指定すればPydanticによる検証対象にできるので、ここでは認可スコープとしてSCOPE_VALUEを定義して使用している。
キーの失効日時expires_atを指定した場合は、APIキー認証時に現在日時と比較して期限切れを判定する。
DB設定
shared/database.pyに以下のDB設定を定義している。
これについては特別なことはなく、sqlalchemyでよくある設定である。
get_db()はRoutes層で依存性注入を使って参照する。
import os
from collections.abc import Generator

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker


DATABASE_URL = os.getenv(“DATABASE_URL”)

if not DATABASE_URL:
    raise RuntimeError("DATABASE_URLが設定されていません")


engine = create_engine(
    DATABASE_URL,
    pool_pre_ping=True,
)

SessionLocal = sessionmaker(
    bind=engine,
    autoflush=False,
    autocommit=False,
)


class Base(DeclarativeBase):
    pass


def get_db() -> Generator[Session, None, None]:
    db = SessionLocal()

    try:
        yield db
    finally:
        db.close()

APIキーの管理方法

APIキー管理(発行・一覧取得・更新・無効化)を行うためのAPIエンドポイントの実装例。
本来ならAPIキーを管理する画面を実装してそこから呼ぶべきものであるが、今回はバックエンドのコードだけを示し、curlコマンドなどで操作する前提とする。
なお、本記事ではAPIキーそのものの管理処理に焦点を当てるため、これ以降に出てくる管理用API自体の認証・認可は省略している。
実際のシステムでは、管理画面へのログイン認証や管理者権限の確認などを行い、権限を持たない第三者がAPIキーを自由に発行・参照・無効化できないようにする必要がある。

APIキー管理のmain.py

まずはAPIキー管理の起点となるmain.pyから。
FastAPIの呼び出しやルーティングは基本的なもので、api_keys_routerは後ほどRoutes層で示すものになる。
特に、各エンドポイントで起こりうるエラーをexception_handlerの形でmain.pyに定義し一括して捕捉されるようにしていて、Routes層の各エンドポイントはtry-exceptブロックを使わずに書けるのでだいぶスリムになる。
ここでは、422:Pydanticでチェックされるリクエスト形式のバリデーションエラー(内容はPydanticが返す詳細をそのまま表示)、404:指定のAPIキーレコードがDB上で見つからないエラー、500:その他想定外のエラー、程度を捕捉する。
from fastapi import FastAPI, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from api_key_manage.src.routes.api_keys import router as api_keys_router
from starlette import status
from starlette.responses import JSONResponse
from api_key_manage.src.services.api_keys import ApiKeyNotFoundError

app = FastAPI()
app.include_router(api_keys_router)


@app.exception_handler(RequestValidationError)
async def request_validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
    """ リクエスト形式のバリデーションエラーを補足する共通ハンドラ """
    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
        content={"detail": jsonable_encoder(exc.errors())},
    )


@app.exception_handler(ApiKeyNotFoundError)
async def api_key_not_found_handler(request: Request, exc: ApiKeyNotFoundError) -> JSONResponse:
    """ APIキーが見つからないエラーを補足する共通ハンドラ """
    return JSONResponse(
        status_code=status.HTTP_404_NOT_FOUND,
        content={"detail": "API key not found."},
    )


@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
    """ 想定外の例外を捕捉する共通ハンドラ """
    return JSONResponse(
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        content={"detail": "Internal Server Error."},
    )

各種APIの仕様

APIキー生成
エンドポイント:POST /api_keys
secretsでランダム生成した文字列に、今回はsk-の接頭辞を付けたものをAPIキーとする。
また、作成したAPIキーのハッシュ化にはSHA-256を利用する。
リクエストボディではAPIキー名称、認可スコープ、失効日時(任意)を指定するものとして、その値をそのままAPIキーレコードに登録する。
大事なのは生成処理のレスポンスボディに生のAPIキーを含めておくことで、キーを生成したユーザーは唯一そこから生のAPIキーを知ることができる。
APIキー生成画面を実装する場合であれば、このレスポンスボディを使って生のAPIキーを画面に一度だけ表示したりする。
APIキー一覧取得
エンドポイント:GET /api_keys?active_filter=
作成済みのAPIキーレコードを一括で取得する。今回はページングとかは考えていない。
active_filterをクエリパラメータとして受け取り、Trueの場合は現在有効なAPIキーのみを取得する。(デフォルトはFalse)
APIキー更新
エンドポイント:PATCH /api_keys/{key_id}
APIキーのIDを指定し、リクエストボディで指定した属性の値で書き換える。
どの属性を更新可能とするかは運用の想定により異なると思うが、今回はAPIキー名称だけを更新対象とする。
更新処理の前に指定IDに対応するAPIキーレコードがあるかどうかをチェックし、なければ404:ApiKeyNotFoundErrorを上げる。
APIキー無効化
エンドポイント:POST /api_keys/{key_id}/revoke
安全のためAPIキーを手動で無効化する機能はあったほうがいい。
対象のAPIキーのIDを指定し、生成時にはデフォルトでTrueになっているis_activeをFalseに更新する。
無効化の場合も更新処理と同様にAPIキーレコードの存在チェックを行う。

各種エンドポイントのコード例

schemas/api_keys.py
リクエストボディを必要とするAPIキー作成・更新はリクエストのスキーマも定義してPydanticの型チェックの対象としているが、それ以外のエンドポイントはレスポンスのスキーマだけ定義している。
リクエストスキーマのクラス定義に@model_validatorの関数を定義すると、入力パラメータに対してカスタムのバリデーションを行うことができる。
引数のmodeafter(モデルの型変換・検証が完了後)を指定すれば、カスタムバリデーション関数の内部でselfを使って属性を参照できるので便利。
CreateApiKeyRequestクラスではscope(認可スコープ)の受け取りを想定しているが、models.pyで定義したSCOPE_VALUEを型に指定しておけばPydanticの検証対象になり、想定外の値が指定された時に自動でValueErrorを発生させてくれる。
APIキー一覧のレスポンスは、GetApiKeysResponseApiKeyOutの二層構造にしている。
APIキーレコードの情報を一括取得してレスポンスボディに詰めて返すが、レコードに含まれるkey_hashは内部処理で使うだけなのでレスポンスには必要がない。
そこで、ApiKeyOutというクラスを定義して必要なものだけを含め、そのApiKeyOutのリストをレスポンスボディとして返す仕様にしている。
from pydantic import BaseModel, ConfigDict, Field, model_validator
from typing import Self
from datetime import datetime, timezone
from api_key_manage.src.crud.api_keys import GetApiKeysCrudResult, CreateApiKeyCrudResult
from shared.models import ApiKey, SCOPE_VALUE


class ApiKeyOut(BaseModel):
    """ 公開してよいAPIキー情報 """
    model_config = ConfigDict(from_attributes=True)

    id: int
    name: str
    key_prefix: str
    is_active: bool
    scope: str
    last_used_at: datetime | None
    created_at: datetime
    updated_at: datetime
    # key_hash は入れない(内部専用なので外に出さない)


class GetApiKeysResponse(BaseModel):
    """ APIキー一覧取得レスポンス """
    api_keys: list[ApiKeyOut]

    @classmethod
    def from_crud(cls, result: GetApiKeysCrudResult) -> "GetApiKeysResponse":
        return cls(api_keys=[ApiKeyOut.model_validate(k) for k in result.api_keys])


class CreateApiKeyRequest(BaseModel):
    """ APIキー作成リクエスト """
    name: str = Field(min_length=1, max_length=100, description="APIキー名称")
    scope: SCOPE_VALUE = Field(
        min_length=1, max_length=100, description="認可スコープ")
    expires_at: datetime | None = Field(default=None, description="失効日時")

    @model_validator(mode="after")
    def check_expires_at(self) -> Self:
        if self.expires_at is None:
            return self
        if self.expires_at <= datetime.now(timezone.utc):
            raise ValueError("Invalid expires_at is specified.")
        return self


class CreateApiKeyResponse(BaseModel):
    """ APIキー作成レスポンス """
    id: int
    name: str
    api_key: str  # 生のAPIキー
    scope: str
    created_at: datetime

    @classmethod
    def from_crud(cls, result: CreateApiKeyCrudResult) -> "CreateApiKeyResponse":
        api_key = result.api_key
        original_key = result.original_key
        return cls(
            id=api_key.id,
            name=api_key.name,
            api_key=original_key,
            scope=api_key.scope,
            created_at=api_key.created_at,
        )


class UpdateApiKeyRequest(BaseModel):
    """ APIキー更新リクエスト """
    name: str = Field(min_length=1, max_length=255, description="APIキー名称")

    @model_validator(mode="after")
    def require_at_least_one_field(self) -> Self:
        if not self.model_fields_set:
            raise ValueError("At least one field must be provided.")
        return self


class UpdateApiKeyResponse(BaseModel):
    """ APIキー更新レスポンス """
    id: int
    updated_at: datetime

    @classmethod
    def from_model(cls, api_key: ApiKey) -> "UpdateApiKeyResponse":
        return cls(
            id=api_key.id,
            updated_at=api_key.updated_at
        )


class RevokeApiKeyResponse(BaseModel):
    """ APIキー無効化レスポンス """
    id: int
    updated_at: datetime

    @classmethod
    def from_model(cls, api_key: ApiKey) -> "RevokeApiKeyResponse":
        return cls(
            id=api_key.id,
            updated_at=api_key.updated_at
        )
routes/api_keys.py
各エンドポイントではそれぞれschemas.pyに定義したリクエストモデルやレスポンスモデルを指定しているので、Pydanticで適宜パラメータをチェックしてくれる。
Depends(get_db)で依存性注入を利用して最初にDBを取得しており、これを後続のServices層やCRUD層に渡していくことで同一セッションでのDB処理を行うことができる。
前述したようにmain.pyで一括でエラー捕捉を行なっているのでtry-exceptブロックは書いていない。
from fastapi import APIRouter, Depends
from api_key_manage.src.schemas import api_keys as schemas
from api_key_manage.src.services.api_keys import get_api_keys_service, create_api_key_service, update_api_key_service, revoke_api_key_service
from shared.database import get_db
from sqlalchemy.orm import Session

router = APIRouter(prefix="/api_keys", tags=["api_keys"])


@router.get("", response_model=schemas.GetApiKeysResponse)
async def get_api_keys(active_filter: bool = False, db: Session = Depends(get_db)) -> schemas.GetApiKeysResponse:
    """ APIキー一覧取得エンドポイント """
    result = get_api_keys_service(db=db, active_filter=active_filter)

    return schemas.GetApiKeysResponse.from_crud(result)


@router.post("", response_model=schemas.CreateApiKeyResponse)
async def create_api_key(request: schemas.CreateApiKeyRequest, db: Session = Depends(get_db)) -> schemas.CreateApiKeyResponse:
    """ APIキー作成エンドポイント """
    name = request.name.strip()
    scope = request.scope.strip()
    expires_at = request.expires_at
    result = create_api_key_service(
        name=name, scope=scope, expires_at=expires_at, db=db)

    return schemas.CreateApiKeyResponse.from_crud(result)


@router.patch("/{key_id}", response_model=schemas.UpdateApiKeyResponse)
async def update_api_key(
        key_id: int,
        request: schemas.UpdateApiKeyRequest,
        db: Session = Depends(get_db)) -> schemas.UpdateApiKeyResponse:
    """ APIキー更新エンドポイント """
    name = request.name.strip()
    api_key = update_api_key_service(key_id=key_id, name=name, db=db)

    return schemas.UpdateApiKeyResponse.from_model(api_key)


@router.post("/{key_id}/revoke", response_model=schemas.RevokeApiKeyResponse)
async def revoke_api_key(key_id: int, db: Session = Depends(get_db)) -> schemas.RevokeApiKeyResponse:
    """ APIキー無効化エンドポイント """
    api_key = revoke_api_key_service(key_id=key_id, db=db)

    return schemas.RevokeApiKeyResponse.from_model(api_key)
services/api_keys.py
基本的にRoutes層から直接呼ばれるところで、DB処理以外の様々な処理を行う。 Routes層からのリクエストに応じてCRUD層から取ったDBレコードを元に様々な処理を施してRoutes層に返すイメージ。
ただし、後述するCRUD層の「関数:DBアクセス = 1:1」の考え方に基けば、DB処理のトランザクションを確定するdb.commit()はServices層で行うほうがいい。
また、APIキー更新と無効化については、ORMで取得したインスタンスの属性を書き換えてdb.commit()するだけでいいのでCRUD側の関数を呼び出すことはしていない。(CRUD側にも更新専用の関数は定義していない)
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from api_key_manage.src.crud.api_keys import GetApiKeysCrudResult, CreateApiKeyCrudResult, get_api_key_from_id_crud, get_api_keys_crud, create_api_key_crud
from shared.models import ApiKey


class ApiKeyNotFoundError(Exception):
    """ 指定のAPIキーが見つからないエラー """


def _get_now_utc() -> datetime:
    """ 現在時刻(UTC)を取得する """
    return datetime.now(timezone.utc)


def get_api_keys_service(db: Session, active_filter: bool = False) -> GetApiKeysCrudResult:
    """ APIキー一覧取得処理 """
    result = get_api_keys_crud(db, active_filter)

    return result


def create_api_key_service(name: str, scope: str, expires_at: datetime | None, db: Session) -> CreateApiKeyCrudResult:
    """ APIキー作成処理 """
    try:
        result = create_api_key_crud(name, scope, expires_at, db)
        db.commit()
        return result
    except Exception:
        db.rollback()
        raise


def update_api_key_service(key_id: int, name: str, db: Session) -> ApiKey:
    """ APIキー更新処理 """
    try:
        # 指定のAPIキーを取得
        api_key = get_api_key_from_id_crud(key_id, db)
        if not api_key:
            raise ApiKeyNotFoundError()

        # APIキー情報を更新する
        api_key.name = name
        api_key.updated_at = _get_now_utc()
        db.commit()

        return api_key
    except Exception:
        db.rollback()
        raise


def revoke_api_key_service(key_id: int, db: Session) -> ApiKey:
    """ APIキー無効化処理 """
    try:
        # 指定のAPIキーを取得
        api_key = get_api_key_from_id_crud(key_id, db)
        if not api_key:
            raise ApiKeyNotFoundError()

        if api_key.is_active:
            # キーが有効な場合だけ更新日時を最新化
            api_key.updated_at = _get_now_utc()
        api_key.is_active = False
        db.commit()

        return api_key
    except Exception:
        db.rollback()
        raise
crud/api_keys.py
CRUD層は個々のDB操作に限定し、関数とDBアクセスの役割は1対1、つまり1つの関数の中でdb.xxx()を書くのは1回だけになるように整理している。 複数のDB操作を組み合わせる処理やトランザクション境界はServices層で管理する方針とする。(Unit of Workの原則)
import hashlib
import secrets
from dataclasses import dataclass
from datetime import datetime
from shared.models import ApiKey
from sqlalchemy import select
from sqlalchemy.orm import Session

API_KEY_PREFIX = "sk-"
API_KEY_SHOW_LENGTH = 8


# crud用クラスの定義
# crud層とservice層の間だけでやり取りする内部の戻り値なので外部入力の検証(pydantic)は不要。
# むしろ、ApiKeyクラスはSQLAlchemyのORMモデルなので、pydanticのBaseModelとして定義するとunable to generate pydantic-core schema for <class 'shared.models.ApiKey'>で失敗


@dataclass
class GetApiKeysCrudResult:
    """ APIキー一覧取得CRUDの返却値用クラス """
    api_keys: list[ApiKey]


@dataclass
class CreateApiKeyCrudResult:
    """ APIキー作成CRUDの返却値用クラス """
    api_key: ApiKey
    original_key: str


def get_api_key_from_id_crud(key_id: int, db: Session) -> ApiKey:
    """ IDからAPIキーを1件取得するDB処理 """
    return db.scalar(select(ApiKey).where(ApiKey.id == key_id))


def get_api_keys_crud(db: Session, active_filter: bool = False) -> GetApiKeysCrudResult:
    """ 現在作成されているAPIキー一覧を取得するDB処理 """
    stmt = select(ApiKey)
    if active_filter:
        # 有効なAPIキーのみ取得するwhere句を追加
        stmt = stmt.where(ApiKey.is_active == True)
    return GetApiKeysCrudResult(db.scalars(stmt).all())


def create_api_key_crud(name: str, scope: str, expires_at: datetime | None, db: Session) -> CreateApiKeyCrudResult:
    """ APIキー作成のDB処理 """
    token = secrets.token_urlsafe(32)
    # 生のAPIキー
    original_key = API_KEY_PREFIX + token
    # SHA256でAPIキーをハッシュ変換
    key_hash = hashlib.sha256(original_key.encode("utf-8")).hexdigest()

    api_key = ApiKey(
        name=name,
        key_hash=key_hash,
        key_prefix=original_key[:API_KEY_SHOW_LENGTH],
        is_active=True,
        scope=scope,
        expires_at=expires_at,
        last_used_at=None,
    )
    db.add(api_key)
    return CreateApiKeyCrudResult(api_key=api_key, original_key=original_key)

API認証の実装例

次は、何らかの公開APIをAPIキーを利用して実行するために必要な考え方をまとめる。
例として、以下のような生徒IDと教科別のテストの点数を記録しているscoresテーブルのデータを使って色々やるAPIを定義する。
今回は、指定した1教科の基本的な統計量(レコード数、最大、最小、平均、生徒毎の偏差値)を計算して返すGETエンドポイントと、レコードを追加するPOSTエンドポイントを作ってみる。
この公開APIはpublic_api配下に実装している。
main.pyファイルとroutesservicescrudschemasフォルダという配置はapi_key_manageと同じであるが、servicescrudにはrouterのprefixと同じ名前のscores.py以外に、APIキー検証用のauth.pyがあることが異なる。
.
├── Dockerfile
├── alembic
├── alembic.ini
├── api_key_manage
├── compose.yaml
├── public_api
│   └── src
│       ├── __init__.py
│       ├── crud
│       │   ├── __init__.py
│       │   ├── auth.py
│       │   └── scores.py
│       ├── main.py
│       ├── routes
│       │   ├── __init__.py
│       │   └── scores.py
│       ├── schemas
│       │   ├── __init__.py
│       │   └── scores.py
│       └── services
│           ├── __init__.py
│           ├── auth.py
│           └── scores.py
├── requirements.txt
└── shared
    ├── database.py
    └── models.py

APIによる認証・認可

APIにリクエストが投げられる際、APIキーはX-Api-Keyヘッダーに付与されて来る想定とする。
エンドポイントの実行時にリクエストに含まれるAPIキーを検証し、OKならば対象のAPIの処理を開始し、NGならばその理由に応じたエラーレスポンスを返却して終了となる。
APIキーの検証はFastAPIの依存性注入を利用してDepends で行うのが適している。

公開APIのmain.py

こちらもAPIキー管理側のmain.pyと基本的には変わらない。
エラーの一括捕捉のバリエーションもほぼAPIキー管理と同じであるが、scoresレコードを登録する時にユーザーがstudent_idを指定する作りにしているので、student_idの重複エラーを追加している。
from fastapi import FastAPI, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from public_api.src.services.scores import ScoresForSubjectNotFoundError, DuplicateStudentScoreError
from public_api.src.routes.scores import router as scores_router
from starlette import status
from starlette.responses import JSONResponse

app = FastAPI()
app.include_router(scores_router)


@app.exception_handler(RequestValidationError)
async def request_validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
    """ リクエスト形式のバリデーションエラーを補足する共通ハンドラ """
    logger.warning(
        "Request validation error: method=%s path=%s errors=%s",
        request.method,
        request.url.path,
        exc.errors(),
        exc_info=exc,
    )
    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
        content={"detail": jsonable_encoder(exc.errors())},
    )


@app.exception_handler(ScoresForSubjectNotFoundError)
async def score_for_subject_not_found_handler(request: Request, exc: ScoresForSubjectNotFoundError) -> JSONResponse:
    """ スコアレコードが見つからないエラーを補足する共通ハンドラ """
    logger.warning(
        "Scores for the subject not found: method=%s path=%s",
        request.method,
        request.url.path,
        exc_info=exc,
    )
    return JSONResponse(
        status_code=status.HTTP_404_NOT_FOUND,
        content={"detail": "Scores for the subject not found."},
    )


@app.exception_handler(DuplicateStudentScoreError)
async def duplicate_student_score_handler(request: Request, exc: DuplicateStudentScoreError) -> JSONResponse:
    """ スコアレコードの生徒IDが重複しているエラーを補足する共通ハンドラ """
    logger.warning(
        "This student's score record already exists: method=%s path=%s",
        request.method,
        request.url.path,
        exc_info=exc,
    )
    return JSONResponse(
        status_code=status.HTTP_409_CONFLICT,
        content={"detail": "This student's score record already exists."},
    )


@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
    """ 想定外の例外を捕捉する共通ハンドラ """
    logger.error(
        "Unhandled exception: method=%s path=%s",
        request.method,
        request.url.path,
        exc_info=exc,
    )

    return JSONResponse(
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        content={"detail": "Internal Server Error."},
    )

認証・認可処理のコード例

services/auth.py
authorize_api_key()という関数は、ヘッダーからAPIキーを取得&APIキーの正当性チェックを行い、チェックを通過したAPIキーを返却する。
この関数は、可変長位置引数を利用して任意個の認可スコープ文字列を*allowed_scopesとして受け取り、チェック処理の本体となる_checkerという関数を返すものである。
関数を返すので返却値の型指定はCallableを使い、_checker関数自体は特に引数の型・個数は指定しない(問わない)ので...とし、APIキーレコードを返すのでApiKey型を指定している。
_checker()はAPIキーの認可処理を行うもので、依存性注入を利用してauthenticate_api_key()関数を先に解決してAPIキーレコードを取得し、そのAPIキーが持つ認可スコープがallowed_scopesに含まれているかをチェックする。
含まれていればAPIキーレコードをそのまま返すし、含まれていなければそのAPIキーでは指定のAPIを実行する権限はないとみなして403を返す。
authenticate_api_key()はAPIキーの認証処理を行うもので、まずはHeader(default=None, alias="X-API-Key")でリクエストヘッダーからAPIキー文字列を取得を試みる。
ここでは以下のチェックを行い、一つでもNGのものがあれば401を返す。
  • そもそもリクエストにX-API-Keyが付加されているか?
  • APIキーはsk-から始まるか?
  • ハッシュ化したAPIキーと一致するレコードがDBに存在するか?
  • そのAPIキーは有効か?
  • APIキーは失効日時を過ぎていないか?
全部OKであれば指定のAPIキーを使用することができるのでlast_used_atを現在時刻に更新する。
import hashlib
from datetime import datetime, timezone
from fastapi import Depends, Header, HTTPException
from shared.database import get_db
from shared.models import ApiKey, SCOPE_VALUE
from sqlalchemy.orm import Session
from starlette import status
from public_api.src.crud.auth import get_api_key_by_key_hash_crud
from typing import Callable

API_KEY_PREFIX = "sk-"


def _get_now_utc() -> datetime:
    """ 現在時刻(UTC)を取得する """
    return datetime.now(timezone.utc)


def _authenticate_api_key(
    api_key: str | None = Header(default=None, alias="X-API-Key"),
    db: Session = Depends(get_db),
) -> ApiKey:
    """ APIキー文字列の検証を行い正当なAPIキーレコードを返却する """
    try:
        # APIキーが付加されていない
        if not api_key:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="X-Api-Key header is required.",
            )

        # APIキーの形式不正
        if not api_key.startswith(API_KEY_PREFIX):
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Invalid API key.",
            )

        # APIキーをハッシュ化し、それを元にAPIキーレコードを取得する
        key_hash = hashlib.sha256(api_key.encode("utf-8")).hexdigest()
        api_key_recode = get_api_key_by_key_hash_crud(key_hash, db)

        # APIキー文字列と一致するAPIキーレコードが存在しない
        if not api_key_recode:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Invalid API key.",
            )

        # APIキーが無効
        if not api_key_recode.is_active:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="API key has been revoked.",
            )

        # APIキーが有効期限切れ
        if api_key_recode.expires_at and api_key_recode.expires_at <= _get_now_utc():
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="API key has been expired.",
            )

        # チェックOKなのでAPIキーのlast_used_atを現在日時に更新
        api_key_recode.last_used_at = _get_now_utc()
        db.commit()
        return api_key_recode
    except Exception:
        db.rollback()
        raise


def authorize_api_key(*allowed_scopes: SCOPE_VALUE) -> Callable[..., ApiKey]:
    """ 認証 + スコープに基づく認可 を行う """
    # APIキーレコードは依存関係として取得
    def _checker(api_key_recode: ApiKey = Depends(_authenticate_api_key)) -> ApiKey:
        # APIキーレコードのscopeがallowed_scopesリストに含まれていなければ認可失敗
        if api_key_recode.scope not in allowed_scopes:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"This operation requires one of the following scopes: {','.join(allowed_scopes)}."
            )
        return api_key_recode

    return _checker
crud/auth.py
認証処理の中でAPIキーレコードの取得を行うので、その処理をcrudに定義している。
from shared.models import ApiKey
from sqlalchemy import select
from sqlalchemy.orm import Session


def get_api_key_by_key_hash_crud(key_hash: str, db: Session) -> ApiKey | None:
    """ APIキーハッシュ文字列からAPIキーレコードを取得するDB処理 """
    return db.scalar(select(ApiKey).where(ApiKey.key_hash == key_hash))

各種エンドポイントのコード例

APIキー認証・認可の処理を直接呼び出すのはRoutes層になるのでまずは先にroutes.pyの説明から。
APIキー管理の方で作成したエンドポイントと違い、こちらは引数のところで依存性注入を利用してDepends(authorize_api_key)を設定している。
こうすることで、前述のauthorize_api_key()を解決、つまりAPIキーの認証・認可がすべてOKであることが保証された上でAPIを実行することができる。
authorize_api_key()の引数にはそのAPIを利用する権限を認めるスコープを全て与える。
例えば、GET /scores/statisticsread_onlyまたはread_writeのスコープを持つAPIキーであれば実行できるが、POST /scoresread_writeを持つAPIキーしか実行できない。
Depends(authorize_api_key)を付けたエンドポイントは、正しいAPIキーがなければ実行することはできない。
scores/routes.py
from fastapi import APIRouter, Depends
from typing import Literal
from public_api.src.schemas import scores as schemas
from public_api.src.services.auth import authorize_api_key
from public_api.src.services.scores import get_one_subject_scores, calc_statistics, create_score_service
from sqlalchemy.orm import Session
from shared.database import get_db
from shared.models import ApiKey, SCOPE_VALUE

router = APIRouter(prefix="/scores", tags=["scores"])


@router.get("/statistics", response_model=schemas.ScoreStatisticsResponse)
async def get_one_subject_statistics(
    subject: Literal["japanese", "math", "english",
                     "physics", "chemistry", "world_history"],
    _: ApiKey = Depends(authorize_api_key(
        SCOPE_VALUE.READ_ONLY, SCOPE_VALUE.READ_WRITE)),
    db: Session = Depends(get_db)
) -> schemas.ScoreStatisticsResponse:
    """ 1教科のスコアの統計量の計算結果を取得する """
    scores = get_one_subject_scores(subject, db)
    result = calc_statistics(scores.scores)

    return schemas.ScoreStatisticsResponse.from_result(subject, result)


@router.post("", response_model=schemas.CreateScoreResponse)
async def create_score(
    request: schemas.CreateScoreRequest,
    _: ApiKey = Depends(authorize_api_key(SCOPE_VALUE.READ_WRITE)),
    db: Session = Depends(get_db),
) -> schemas.CreateScoreResponse:
    """ スコアレコードを1件作成する """
    result = create_score_service(
        student_id=request.student_id,
        japanese=request.japanese,
        math=request.math,
        english=request.english,
        physics=request.physics,
        chemistry=request.chemistry,
        world_history=request.world_history,
        db=db,
    )

    return schemas.CreateScoreResponse.from_crud(result)
これ以降のscores/schemas.pyscores/services.pyscores/crud.pyは本記事のテーマであるAPIキーの認証・認可には全く関係ないところなので、コードだけ載せて詳細な説明は省略する。(興味があれば処理を追ってみてください。。。)
scores/schemas.py
from pydantic import BaseModel, Field
from public_api.src.services.scores import ScoreStatisticsResult
from public_api.src.crud.scores import CreateScoreCrudResult


class StudentDeviationValueResponse(BaseModel):
    """ 生徒1人分の偏差値(レスポンス用) """
    student_id: int
    deviation_value: float


class ScoreStatisticsResponse(BaseModel):
    """ スコア統計量取得レスポンス """
    subject: str
    count: int
    max: int
    min: int
    average: float
    deviation_values: list[StudentDeviationValueResponse]

    @classmethod
    def from_result(cls, subject: str, result: ScoreStatisticsResult) -> "ScoreStatisticsResponse":
        return cls(
            subject=subject,
            count=result.count,
            max=result.max,
            min=result.min,
            average=result.average,
            deviation_values=[
                StudentDeviationValueResponse(student_id=d.student_id, deviation_value=d.deviation_value) for d in result.deviation_values
            ]
        )


class CreateScoreRequest(BaseModel):
    """ スコアレコード作成リクエスト (各スコア100点満点)"""
    student_id: int = Field(ge=0, description="生徒ID")
    japanese: int = Field(ge=0, le=100, description="国語のスコア")
    math: int = Field(ge=0, le=100, description="数学のスコア")
    english: int = Field(ge=0, le=100, description="英語のスコア")
    physics: int = Field(ge=0, le=100, description="物理のスコア")
    chemistry: int = Field(ge=0, le=100, description="化学のスコア")
    world_history: int = Field(ge=0, le=100, description="世界史のスコア")


class CreateScoreResponse(BaseModel):
    """ スコアレコード作成レスポンス """
    id: int
    student_id: int
    japanese: int
    math: int
    english: int
    physics: int
    chemistry: int
    world_history: int

    @classmethod
    def from_crud(cls, result: CreateScoreCrudResult) -> "CreateScoreResponse":
        score = result.score
        return cls(
            id=score.id,
            student_id=score.student_id,
            japanese=score.japanese,
            math=score.math,
            english=score.english,
            physics=score.physics,
            chemistry=score.chemistry,
            world_history=score.world_history,
        )
scores/services.py
import statistics
from dataclasses import dataclass
from sqlalchemy.orm import Session
from public_api.src.crud.scores import StudentScore, GetScoresCrudResult, CreateScoreCrudResult, get_scores_by_subject, get_score_by_student_id, create_score_crud


class ScoresForSubjectNotFoundError(Exception):
    """ 指定教科に対するスコアレコードが見つからないエラー """


class DuplicateStudentScoreError(Exception):
    """ スコアレコードの生徒IDが重複しているエラー """


@dataclass
class StudentDeviationValue:
    """ 生徒1人分の偏差値 """
    student_id: int
    deviation_value: float


@dataclass
class ScoreStatisticsResult:
    """ 統計量計算処理の返却値用クラス """
    count: int
    max: int
    min: int
    average: float
    deviation_values: list[StudentDeviationValue]


def get_one_subject_scores(subject: str, db: Session) -> GetScoresCrudResult:
    """ 1教科の全スコアレコードを取得する処理 """
    scores = get_scores_by_subject(subject, db)
    if not scores:
        raise ScoresForSubjectNotFoundError

    return scores


def calc_statistics(scores: list[StudentScore]) -> dict:
    """
        指定教科の統計量を計算して返却する
        スコアの全体統計(最大値、最小値、平均値)と生徒毎の偏差値を計算する
    """
    # 点数のリスト
    values = [s.score for s in scores]
    average = round(statistics.mean(values), 1)
    standard_deviation = statistics.pstdev(values)

    def _deviation_value(score: int) -> float:
        # 偏差値の計算
        if standard_deviation == 0:
            return 50.0
        else:
            return round(50 + 10 * (score - average) / standard_deviation, 1)

    deviation_values = [StudentDeviationValue(
        student_id=s.student_id, deviation_value=_deviation_value(s.score)) for s in scores]

    return ScoreStatisticsResult(
        count=len(values),
        max=max(values),
        min=min(values),
        average=average,
        deviation_values=deviation_values,
    )


def create_score_service(
    student_id: int,
    japanese: int,
    math: int,
    english: int,
    physics: int,
    chemistry: int,
    world_history: int,
    db: Session,
) -> CreateScoreCrudResult:
    """ スコアレコードを1件作成する処理 """
    try:
        exist_score = get_score_by_student_id(student_id, db)
        if exist_score:
            raise DuplicateStudentScoreError
        result = create_score_crud(
            student_id=student_id,
            japanese=japanese,
            math=math,
            english=english,
            physics=physics,
            chemistry=chemistry,
            world_history=world_history,
            db=db,
        )
        db.commit()
        return result
    except Exception:
        db.rollback()
        raise
scores/crud.py
from dataclasses import dataclass
from sqlalchemy import select, Sequence, RowMapping
from sqlalchemy.orm import Session
from shared.models import Score

_SCORE_COLUMNS = {
    "student_id": Score.student_id,
    "japanese": Score.japanese,
    "math": Score.math,
    "english": Score.english,
    "physics": Score.physics,
    "chemistry": Score.chemistry,
    "world_history": Score.world_history,
}


@dataclass
class StudentScore:
    """ 生徒IDとスコアのセットデータ """
    student_id: int
    score: int

    @classmethod
    def from_query_response(cls, subject: str, row: Sequence[RowMapping]) -> "StudentScore":
        return cls(
            student_id=row["student_id"],
            score=row[subject],
        )


@dataclass
class GetScoresCrudResult:
    """ 指定教科の全スコアレコードを取得するCRUDの返却値用クラス """
    scores: list[StudentScore]


@dataclass
class CreateScoreCrudResult:
    """ スコアレコード作成CRUDの返却値用クラス """
    score: Score


def get_score_by_student_id(student_id: int, db: Session) -> Score:
    """ 生徒IDからスコアレコードを取得するDB処理 """
    return db.scalar(select(Score).where(Score.student_id == student_id))


def get_scores_by_subject(subject: str, db: Session) -> GetScoresCrudResult:
    """
        指定教科の全スコアレコードを取得するDB処理
        (student_id, score)のセットとして取得する
    """
    columns = [_SCORE_COLUMNS[k] for k in ["student_id", subject]]
    rows = db.execute(select(*columns)).mappings().all()

    return GetScoresCrudResult(
        scores=[StudentScore.from_query_response(subject, r) for r in rows])


def create_score_crud(
    student_id: int,
    japanese: int,
    math: int,
    english: int,
    physics: int,
    chemistry: int,
    world_history: int,
    db: Session,
) -> CreateScoreCrudResult:
    """ スコアレコード作成のDB処理 """
    score = Score(
        student_id=student_id,
        japanese=japanese,
        math=math,
        english=english,
        physics=physics,
        chemistry=chemistry,
        world_history=world_history,
    )
    db.add(score)
    return CreateScoreCrudResult(score=score)

API実行デモ

例えばPOST /scoresで正しいAPIキー({{API_KEY}}の部分に入っている)を指定して実行すれば、ステータスコード200で想定通りのレスポンスボディが返ってくる。
X-Api-Keyヘッダーを付けずにリクエストすれば、認証失敗でステータスコード401が返る。
必要な認可スコープを持たないAPIキーでリクエストすれば、認可失敗でステータスコード403が返る。

Discussion

コメントにはログインが必要です。