Flask-Loginで認証が必要なAPIを作成する
Flask-Loginを使うと簡単にユーザーのログイン管理ができるのでとても便利
Flask-Loginの特徴
- メリット
- デメリット
- ユーザーの情報はセッションcookieに保存されるので、サーバー側からユーザーをログアウトする事ができない
ユーザーのcookieが盗まれた場合にcookieを無効化するにはSECRET_KEYを変更するしかないが、実用上は問題はないと思う
Flask-Loginのログイン処理に利用するメソッドなど
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という機能が利用できなくなる
- cookieの値をSECRET KEYと設定された値を使ってハッシュ化して作成しているので、SECRET_KEYを変更するとcookieの値と一致しなくなるため
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の中身を書き換えると良い
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)
if auth_user == {}:
raise LoginFailureError()
res = {"userId": auth_user["userId"], "userName": auth_user["userName"]}
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
user_id = current_user.user_id
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)
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)
if auth_user == {}:
raise LoginFailureError()
res = {"userId": auth_user["userId"], "userName": auth_user["userName"]}
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
user_id = current_user.user_id
res = {"userId": user_id, "userName": user_name}
return jsonify(res), 200
if __name__ == "__main__":
app.run()