🔐

FlaskのBasic認証HTTPBasicAuthで401ページをカスタマイズする

2024/06/04に公開

TL;DR

HTTPBasicAuth.error_handler()の引数にwerkzeug.exceptions.Unauthorized.get_body()の文字列を返すメソッドを渡す。

背景

flask_httpauthのBasic認証HTTPBasicAuthFlaskに適用する際、デフォルトだとUnauthorized(認証失敗)のページは以下のように表示される。

他の404等のエラーページではもう少しリッチな表示になっている一方で、401の時のみただの1行テキストでかなり質素な表示となり味気ない。

対策

HTTPBasicAuth.error_handler()で自前のエラーメッセージを返すように再設定する。

実装例

server.py
from flask import Flask
from flask_httpauth import HTTPBasicAuth
from werkzeug.exceptions import Forbidden, InternalServerError, Unauthorized


def main() -> None:
    app: Flask = Flask(__name__)
    auth: HTTPBasicAuth = HTTPBasicAuth(realm="My auth")

    http_errors: dict[int, str] = {
        code: error().get_body()
        for code, error in {
            401: Unauthorized,
            403: Forbidden,
            500: InternalServerError,
        }.items()
    }

    def handle_error(status: int) -> tuple[str, int]:
        return http_errors.get(status, http_errors[500]), status

    auth.error_handler(handle_error)

    @auth.get_password
    def get_auth_password(username: str) -> str:
        return "password" if username == "admin" else None

    @app.get("/")
    @auth.login_required
    def index() -> str:
        return "Hello world"

    app.run(host="0.0.0.0", port=8888)


if __name__ == "__main__":
    main()

認証エラーページ

説明

flask_httpauth.HTTPAuth.__init__()において、エラーハンドラerror_handler()に対して内部メソッドdefault_auth_error()を渡している。
これにより、認証エラー時にはこのメソッドの戻り値が返されるようになるが、ここでデフォルトのUnauthorized Accessのテキストが返るようにしている。

flask_httpauth.py
class HTTPAuth(object):
    def __init__(self, scheme=None, realm=None, header=None):
        self.scheme = scheme
        self.realm = realm or "Authentication Required"
        self.header = header
        self.get_password_callback = None
        self.get_user_roles_callback = None
        self.auth_error_callback = None

        def default_get_password(username):
            return None

        def default_auth_error(status):
            return "Unauthorized Access", status

        self.get_password(default_get_password)
        self.error_handler(default_auth_error)

一方で、Flaskの標準エラーページはwerkzeug.exceptions以下のエラークラスを用いており、すべてExceptionを継承したHTTPExceptionクラスを継承している。
HTTPException.get_body()で自身の持つステータスコードや説明を用いてHTMLの文字列を構築しており、ブラウザ上ではこの文字列がページとして表示されてるようになっている。

werkzeug/exceptions.py
class HTTPException(Exception):
・・・
    def get_body(
        self,
        environ: WSGIEnvironment | None = None,
        scope: dict | None = None,
    ) -> str:
        """Get the HTML body."""
        return (
            "<!doctype html>\n"
            "<html lang=en>\n"
            f"<title>{self.code} {escape(self.name)}</title>\n"
            f"<h1>{escape(self.name)}</h1>\n"
            f"{self.get_description(environ)}\n"
        )

Unauthorizedクラスはこれに対してデフォルトで以下のようなコードと説明が設定されるので、HTML文字列を構築した結果を取り出しておけば後はHTTPAuth.error_handler()でデフォルトのメソッドと同様にこの文字列とステータスを返却すれば良い。

werkzeug/exceptions.py
class Unauthorized(HTTPException):
・・・
    code = 401
    description = (
        "The server could not verify that you are authorized to access"
        " the URL requested. You either supplied the wrong credentials"
        " (e.g. a bad password), or your browser doesn't understand"
        " how to supply the credentials required."
    )

ただし、API仕様を見ると403エラーも来たりなど必ずしも401となるわけではないようなので、例では複数のステータスが来る可能性を考慮して引数のstatusを元にHTMLを切り替えるようにしている。

Discussion