skydum

個人的な作業記録とか備忘録代わりのメモ

Flask-Loginで認証が必要なAPIを作成する

Flask-Loginで認証が必要なAPIを作成する

Flask-Loginを使うと簡単にユーザーのログイン管理ができるのでとても便利

Flask-Loginの特徴

  • メリット
    • 簡単にログインが必要なAPIを作成できる
  • デメリット
    • ユーザーの情報はセッションcookieに保存されるので、サーバー側からユーザーをログアウトする事ができない
      ユーザーのcookieが盗まれた場合にcookieを無効化するにはSECRET_KEYを変更するしかないが、実用上は問題はないと思う

Flask-Loginのログイン処理に利用するメソッドなど

Flask-Loginのインストール

$ pip install flask-login

Flask-Loginを利用するための手順

  1. app.config["SECRET_KEY"]の作成
  2. login_managerの作成
  3. class User(UserMixin)の作成
  4. /login APIの作成
  5. ログインしていない場合エラーとなる/user APIとユーザー情報を読み込むuser_loaderの作成
  6. /logout APIの作成

1. app.config["SECRET_KEY"]の作成

  • Flask-LoginはFlask側ではユーザーのセッション情報を保持しない
  • ユーザーの情報はセッション cookieに保存されるのでcookieを改竄されるとログインができてしまうため、
    改ざんされないように暗号化をするのに利用する為のSECRET_KEYを作成して設定する
  • SECRET_KEYを毎回ランダムな値とした場合cookieからユーザー情報を復元するremenber meという機能が利用できなくなる
    • cookieの値をSECRET KEYと設定された値を使ってハッシュ化して作成しているので、SECRET_KEYを変更するとcookieの値と一致しなくなるため
# ランダムな文字列を生成する
# python -c 'import secrets; print(secrets.token_hex())'
app.config["SECRET_KEY"] = "ランダムな文字列"

2. login_managerの作成

  • Flask-LoginがユーザーIDからユーザー情報を復元する方法や、ログインする処理などをFlaskと連携するために利用するオブジェクトを作成する
from flask_login import LoginManager
from flask import Flask

app = Flask(__name__)
login_manager = LoginManager()
login_manager.init_app(app)

3. class User(UserMixin)の作成

  • Flask-Loginがユーザーを管理する際に利用するクラスを作成する
  • Userクラスに入れた情報はAPIの中でcurrent_userとして取得ができるようになるので、後で利用したい情報があればこのクラスに入れておく
  • 今回はユーザーを一意に特定できるuser_idとuser_nameを指定する
class User(UserMixin):
    def __init__(self, user_id: str) -> None:
        """user_idにはユーザーを一意に特定できる値を指定し、その他の項目は任意で設定する

        Args:
            user_id (str): ユーザーを一意に特定できる値
        """
        self.user_id = user_id

    def get_id(self):
        """このメソッドは必要に応じて定義する必要があり、デフォルトではself.idの値が返されるので、
        self.id以外の値を返したい場合はget_idのメソッドを上書きする
        self.get_idで返す値はユーザーを一意に特定できる値を返すこと
        仮に単一の項目では一意の値とならない場合は複数項目をTuple(ハッシュ化可能な値)で返すと良い

        def __init__(self, user_id:str, group_id:str):
            self.user_id = user_id
            self.group_id = group_id

        def get_id(self):
            return (self.user_id, self.grop_id)
        """
        return self.user_id

4. /login APIの作成

  • /loginにアクセスされた際に呼ばれるエンドポイント
  • getのurlパラメーターとしてuserName, passwordが渡される
  • user_authenticationに渡されたuserName, passwordに一致するデータを渡すと何らかのデータベース等から探して一致するデータあった場合、一致するデータを返す
    必要に応じてuser_authenticationの中身を書き換えると良い
# ここに定義されているuserIdはUSER_DATABASEないで一意の値となっていること
USER_DATABASE = [
    {"userId": "test-user", "password": "PASSWORD", "userName": "foo bar"},
    {"userId": "test-user2", "password": "PASSWORD2", "userName": "foo2 bar2"},
]
@app.route("/login", methods=["GET"])
def login():
    """GETのパラメータとして渡されたuserNameとpasswordと一致するデータを
    USER_DATABASEから探して一致するデータあればログインを許可。
    USER_DATABASEから取得したデータをレスポンスとして返す

    Raises:
        BadLoginReuquestError: userName, passwordの片方または両方が指定されていない場合
        LoginFailureError: USRE_DATABASEに一致するユーザーが存在しない場合

    Returns:
        Dict[str, str]: {"userId": ユーザーを一意に特定できる値, "userName": ユーザー名}
    """

    user_name = request.args.get("userName", None)
    password = request.args.get("password", None)

    if not all([user_name, password]):
        raise BadLoginReuquestError()

    auth_user = user_authentication(user_name, password)  # type: ignore

    if auth_user == {}:
        raise LoginFailureError()

    res = {"userId": auth_user["userId"], "userName": auth_user["userName"]}

    # 後でログインが必要なページにアクセスした際にユーザーを一意に特定できる値をUserオブジェクトに設定する
    # Userクラスに指定したuser_idの値がcookieに設定され、後で呼び出されるuser_loaderでuser_idが利用される
    user = User(auth_user["userId"])
    login_user(user, remember=True)

    return jsonify(res), 200
def user_authentication(user_name: str, password: str) -> Dict[str, str]:
    """user_nameとパスワードと一致するデータをUSER_DATABASEから探して一致するデータがあれば
    取得したユーザーのデータを返す

    Args:
        user_name (str): ユーザー名
        password (str): パスワード

    Returns:
        Dict[str, str]: 見つかったユーザー情報を返す、見つからない時は空のDictを返す

    Return Examples:
          match: {"uesrId": "test-user", "userName": "foo bar"}
       no match: {}
    """
    auth = [
        {"userId": value["userId"], "userName": value["userName"]}
        for value in USER_DATABASE
        if user_name == value["userName"] and password == value["password"]
    ]

    if auth == []:
        return {}

    return auth[0]

5. ログインしていない場合エラーとなる/user APIの作成

  • Flaskのエンドポイントに@login_requiredをつけるとログインしていない場合エラーとなるAPIを作ることができる
  • Flask内部での処理の順番
    1. /user にアクセスが来る
    2. @login_manager.user_loaderが呼び出される
    3. @login_manager.user_loaderの中でuser_idの値が渡されるので、user_idをもとにユーザー情報を取得して返す
    4. @login_manager.ueer_loaderからのレスポンスがNoneの場合はログインをしていないとして@login_manager.unauthorized_handlerが呼び出される
@app.route("/user", methods=["GET"])
@login_required
def show_user_data():
    """現在ログインしているユーザーの情報を返す"""
    user_name = current_user.user_name  # type: ignore
    user_id = current_user.user_id  # type: ignore

    res = {"userId": user_id, "userName": user_name}
    return jsonify(res), 200
  • @login_manager.user_loader
@login_manager.user_loader
def load_user(user_id: str):
    """ログインが必要なAPIにアクセスした際にcookieからuser_idを取得して
    ユーザーの情報を検索してAPIにユーザーの情報を返す
    見つからない時はNoneを返すこと

    Args:
        user_id (str): ユーザーを一意に特定できる値

    Returns:
        Union[User, None]: ユーザー情報を見つけた時はUserオブジェクトを見つからない時はNone
    """
    user_data = [value for value in USER_DATABASE if user_id == value["userId"]]

    if user_data == []:
        return None

    user = User(user_data[0]["userId"])

    return user
  • @login_manager.unauthorized_handler
@login_manager.unauthorized_handler
def unatuhorized_handler():
    error_msg = {"errorMessage": "ログインが必須のページです", "statusCode": 403}
    return jsonify(error_msg), 403

6. logout APIの作成

  • ユーザーのログアウトを行うにはlogout_user()を呼び出すとブラウザのcookieが削除される
@app.route("/logout", methods=["GET"])
def logout():
    """ユーザーをログアウトする"""
    logout_user()

    return jsonify({}), 200

Flaskのエラー処理

  • Flaskで例外が発生するとHTTPExceptionを基底とするエラーが発生するのでエラーハンドラーにHTTPExceptionを登録するとエラー処理がやりやすい
  • 他にもAPIで独自にExceptionを作成して@app.errorhandlerに登録するとエラー処理が一箇所で済むので見通しが良い
from werkzeug.exceptions import HTTPException

@app.errorhandler(HTTPException)
def http_error(e):
    """Flaskのエラー
    """
    error_msg = {"errorMessage": e.description, "statusCode": e.code}

    return jsonify(error_msg), e.code
  • /loginの所にあるLoginFailureErrorは以下のよう例外クラスを作成すると便利
  • API中で発生したAuthenticationError,BadLoginReuquestError,LoginFailureErrordef authentication_error(e)の所に飛んでくる
class AuthenticationError(Exception):
    """アプリケーション内で利用する基底のエラークラス"""

    msg = ""
    status_code = 500


class BadLoginReuquestError(AuthenticationError):
    msg = "userNameとpasswordの指定は必須です"
    status_code = 400


class LoginFailureError(AuthenticationError):
    msg = "userNameまたはpasswordが誤っています"
    status_code = 401
  • エラーハンドラーの作成
@app.errorhandler(AuthenticationError)
def authentication_error(e):
    error_msg = {"errorMessage": e.msg, "statusCode": e.status_code}
    return jsonify(error_msg), e.status_code

APIのソースとサンプルのリクエス

### LOGIN OK
GET http://127.0.0.1:5000/login?userName=foo%20bar&password=PASSWORD

### GET /user
GET http://127.0.0.1:5000/user

### GET /logout
GET http://127.0.0.1:5000/logout
import traceback
from typing import Dict

from flask import Flask, jsonify, request
from flask_login import LoginManager, UserMixin, current_user, login_required, login_user, logout_user
from werkzeug.exceptions import HTTPException

app = Flask(__name__)

# 日本語の文字化け対策
app.config["JSON_AS_ASCII"] = False

app.config["SECRET_KEY"] = "9abecf1b701c38448a21bbba5bad84d7c6e7e0255961a9e891d42efa8c989dcc"

login_manager = LoginManager()
login_manager.init_app(app)


# ここに定義されているuserIdはUSER_DATABASEないで一意の値となっていること
USER_DATABASE = [
    {"userId": "test-user1", "password": "PASSWORD", "userName": "foo bar"},
    {"userId": "test-user2", "password": "PASSWORD2", "userName": "foo2 bar2"},
]


class AuthenticationError(Exception):
    """アプリケーション内で利用する基底のエラークラス"""

    msg = ""
    status_code = 500


class BadLoginReuquestError(AuthenticationError):
    msg = "userNameとpasswordの指定は必須です"
    status_code = 400


class LoginFailureError(AuthenticationError):
    msg = "userNameまたはpasswordが誤っています"
    status_code = 401


@app.errorhandler(AuthenticationError)
def authentication_error(e):
    error_msg = {"errorMessage": e.msg, "statusCode": e.status_code}
    return jsonify(error_msg), e.status_code


def page_not_found(e):
    return "404 Not found", 404


@app.errorhandler(HTTPException)
def http_error(e):
    """Flaskのエラー

    Args:
        e (_type_): _description_

    Returns:
        _type_: _description_
    """
    error_msg = {"errorMessage": e.description, "statusCode": e.code}

    return jsonify(error_msg), e.code


@app.errorhandler(Exception)
def error_handler(e):
    info = traceback.format_exc()

    if not getattr(e, "args"):
        error_msg = {"errorMessage": "error", "traceBack": info, "statusCode": 500}
    elif getattr(e, "args") and isinstance(e.args, tuple):
        error_msg = {"errorMessage": e.args[0], "traceBack": info, "statusCode": 500}
    else:
        error_msg = {"errorMessage": "error", "traceBack": info, "statusCode": 500}

    return jsonify(error_msg), 500


@login_manager.unauthorized_handler
def unatuhorized_handler():
    error_msg = {"errorMessage": "ログインが必須のページです", "statusCode": 403}
    return jsonify(error_msg), 403


class User(UserMixin):
    def __init__(self, user_id: str) -> None:
        """user_idにはユーザーを一意に特定できる値を指定し、その他の項目は任意で設定する

        Args:
            user_id (str): ユーザーを一意に特定できる値
        """
        self.user_id = user_id

    def get_id(self):
        """このメソッドは必要に応じて定義する必要があり、デフォルトではself.idの値が返されるので、
        self.id以外の値を返したい場合はget_idのメソッドを上書きする
        self.get_idで返す値はユーザーを一意に特定できる値を返すこと
        仮に単一の項目では一意の値とならない場合は複数項目をTuple(ハッシュ化可能な値)で返すと良い

        def __init__(self, user_id:str, group_id:str):
            self.user_id = user_id
            self.group_id = group_id

        def get_id(self):
            return (self.user_id, self.grop_id)
        """
        return self.user_id


def user_authentication(user_name: str, password: str) -> Dict[str, str]:
    """user_nameとパスワードと一致するデータをUSER_DATABASEから探して一致するデータがあれば
    取得したユーザーのデータを返す

    Args:
        user_name (str): ユーザー名
        password (str): パスワード

    Returns:
        Dict[str, str]: 見つかったユーザー情報を返す、見つからない時は空のDictを返す

    Return Examples:
          match: {"uesrId": "test-user", "userName": "foo bar"}
       no match: {}
    """
    auth = [
        {"userId": value["userId"], "userName": value["userName"]}
        for value in USER_DATABASE
        if user_name == value["userName"] and password == value["password"]
    ]

    if auth == []:
        return {}

    return auth[0]


@login_manager.user_loader
def load_user(user_id: str):
    """user_idにはcookieから取得したclass Userのget_idで指定した値が入ってくる
    ログインが必要なAPIにアクセスした際にcookieからuser_idを取得して
    ユーザーの情報を検索してAPIにユーザーの情報を返す
    見つからない時はNoneを返すこと

    Args:
        user_id (str): ユーザーを一意に特定できる値

    Returns:
        Union[User, None]: ユーザー情報を見つけた時はUserオブジェクトを見つからない時はNone
    """
    user_data = [value for value in USER_DATABASE if user_id == value["userId"]]

    if user_data == []:
        return None

    user = User(user_data[0]["userId"])

    return user


@app.route("/login", methods=["GET"])
def login():
    """GETのパラメータとして渡されたuserNameとpasswordと一致するデータを
    USER_DATABASEから探して一致するデータあればログインを許可。
    USER_DATABASEから取得したデータをレスポンスとして返す

    Raises:
        BadLoginReuquestError: userName, passwordの片方または両方が指定されていない場合
        LoginFailureError: USRE_DATABASEに一致するユーザーが存在しない場合

    Returns:
        Dict[str, str]: {"userId": ユーザーを一意に特定できる値, "userName": ユーザー名}
    """

    user_name = request.args.get("userName", None)
    password = request.args.get("password", None)

    if not all([user_name, password]):
        raise BadLoginReuquestError()

    auth_user = user_authentication(user_name, password)  # type: ignore

    if auth_user == {}:
        raise LoginFailureError()

    res = {"userId": auth_user["userId"], "userName": auth_user["userName"]}

    # 後でログインが必要なページにアクセスした際にユーザーを一意に特定できる値をUserオブジェクトに設定する
    # Userクラスに指定したuser_idの値がcookieに設定され、後で呼び出されるuser_loaderでuser_idが利用される
    user = User(auth_user["userId"])
    login_user(user, remember=True)

    return jsonify(res), 200


@app.route("/logout", methods=["GET"])
def logout():
    """ユーザーをログアウトする"""
    logout_user()

    return jsonify({}), 200


@app.route("/user", methods=["GET"])
@login_required
def show_user_data():
    """現在ログインしているユーザーの情報を返す"""
    user_name = current_user.user_name  # type: ignore
    user_id = current_user.user_id  # type: ignore

    res = {"userId": user_id, "userName": user_name}
    return jsonify(res), 200


if __name__ == "__main__":
    app.run()