Flask-Loginで認証が必要なAPIを作成する
Flask-Loginを使うと簡単にユーザーのログイン管理ができるのでとても便利
Flask-Loginの特徴
- メリット
- 簡単にログインが必要なAPIを作成できる
- デメリット
- ユーザーの情報はセッションcookieに保存されるので、サーバー側からユーザーをログアウトする事ができない
ユーザーのcookieが盗まれた場合にcookieを無効化するにはSECRET_KEYを変更するしかないが、実用上は問題はないと思う- どのようにcookie上にデータ保存されているかは以下を参考
Flask-Loginはremember meをどう実装しているか調査した
https://qiita.com/yu_tomori/items/060bcb945373745bdba0
- どのようにcookie上にデータ保存されているかは以下を参考
- ユーザーの情報はセッションcookieに保存されるので、サーバー側からユーザーをログアウトする事ができない
Flask-Loginのログイン処理に利用するメソッドなど
- Flask-Loginのドキュメント
https://flask-login.readthedocs.io/en/latest/
Flask-Loginのインストール
$ pip install flask-login
Flask-Loginを利用するための手順
- app.config["SECRET_KEY"]の作成
- login_managerの作成
- class User(UserMixin)の作成
- /login APIの作成
- ログインしていない場合エラーとなる/user APIとユーザー情報を読み込むuser_loaderの作成
- /logout APIの作成
1. app.config["SECRET_KEY"]の作成
- Flask-LoginはFlask側ではユーザーのセッション情報を保持しない
- ユーザーの情報はセッション cookieに保存されるのでcookieを改竄されるとログインができてしまうため、
改ざんされないように暗号化をするのに利用する為のSECRET_KEYを作成して設定する - SECRET_KEYを毎回ランダムな値とした場合cookieからユーザー情報を復元するremenber meという機能が利用できなくなる
# ランダムな文字列を生成する # 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内部での処理の順番
- /user にアクセスが来る
- @login_manager.user_loaderが呼び出される
- @login_manager.user_loaderの中でuser_idの値が渡されるので、user_idをもとにユーザー情報を取得して返す
- @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
,LoginFailureError
はdef 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()