FlaskのWebサービスを認証保護してみる
RESTful Webサービスの保護
ここまでで、WebサービスTODOの機能を実装することは終わりました。
しかし、このWebサービスにはアクセス制限がないことが問題となります。
Webサービスを保護する最も簡単な方法は、クライアントにユーザ名とパスワードを要求することです。通常のWebアプリケーションでは、ログイン認証のフォームがあり、ユーザがログインした時点でサーバーはセッションを作成します。そのセッションIDをクライアントブラウザーのCookieに保存しますが、これではRESTのステートレス制約に違反することになります。そのため、クライアントに、リクエストを送信するたびに認証情報を送信するよう依頼する必要があります。
RESTでは、できる限りHTTPプロトコルに準拠するようにします。認証を必要と場合でも、HTTPのコンテキストで実装する必要があります。HTTPは、BasicとDigestの2つの認証形式が提供されています。
このための便利なFlask拡張機能が Flask-HTTPAuth です。
インストール
Flask-HTTPAuthをインストールします。
code: bash
$ pip install Flask-HTTPAuth
単純化するためにAPIは次のようにします。
code: python
from flask import Flask
app = Flask(__name__)
@app.route('/')
def index():
return {"Hello": "World"}
if __name__ == '__main__':
app.run(debug=True, port=8080)
code: bash
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 23
Server: Werkzeug/1.0.1 Python/3.6.10
Date: Mon, 01 Jun 2020 01:55:37 GMT
{
"Hello": "World"
}
ユーザ情報
実際に運用するのであればデータベースなどに格納されているユーザ情報を利用するべきですが、今回は認証方法についての理解を優先するために単純に辞書として保持します。
code: python
class User(object):
def __init__(self, id, username, password):
self.id = id
self.username = username
self.password = password
def __str__(self):
return "User(id='%s')" % self.id
users = [
User(1, 'freddie', 'queen'),
]
username_table = {user.username: user for user in users}
userid_table = {user.id: user for user in users}
認証を追加
app.py に以下のコードを追加します。
code: python
from flask import Flask, jsonify, make_response
from flask_httpauth import HTTPBasicAuth
from werkzeug.security import safe_str_cmp
auth = HTTPBasicAuth()
@auth.verify_password
ef verify_password(username, password):
user = username_table.get(username, None)
if user and safe_str_cmp(user.password.encode('utf-8'),
password.encode('utf-8')):
return user
@auth.error_handler
def unauthorized():
return make_response(jsonify({'error': 'Unauthorized access'}), 401)
関数verify_password()は、Flask-HTTPAuthが特定のユーザーのパスワードを取得するために呼び出すコールバック関数です。
関数 error_handler() は、許可されていないエラーコードをクライアントに送り返す必要があるときに、Flask-HTTPAuth によって呼び出されるコールバック関数です。
エラーコード404のときと同じように、JSONを返すようにします。
保護する必要があるビュー関数(つまり機能)に@auth.login_required デコレーターを追加するだけです
code: python
@app.route('/')
@auth.login_required
def index():
return {"Hello": "World"}
code: bash
HTTP/1.0 401 UNAUTHORIZED
Content-Type: application/json
Content-Length: 37
WWW-Authenticate: Basic realm="Authentication Required"
Server: Werkzeug/1.0.1 Python/3.6.10
Date: Mon, 01 Jun 2020 02:13:33 GMT
{
"error": "Unauthorized access"
}
curl の オプション-u ユーザ名:パスワード でユーザ認証情報をサービスに渡します。
code: bash
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 23
Server: Werkzeug/1.0.1 Python/3.6.10
Date: Mon, 01 Jun 2020 02:13:51 GMT
{
"Hello": "World"
}
WebサービスをSSL対応にする
ここままでは、毎回ユーザ名とパスワードが送信されることになります。
より安全にするためには、クライアントとサーバー間のすべての通信を暗号化し、第三者が送信中の認証資格情報を見ることを防ぐために、WebサービスをHTTPセキュアサーバー(https://...)で公開する必要があります。
困ったことに、Webブラウザーはリクエストが401エラーコード(Unauthorized)で返されたときに、ログインダイアログボックスを表示してしまいます。これはバックグラウンドリクエストでも発生するため、ブラウザーが認証ダイアログを表示しないようにして、クライアントアプリケーションにログインを処理させる必要があります。
このための単純なトリックは、401以外のエラーコードを返すことです。通常これには403エラーコード(Forbidden 禁止)が使われます。ただし、HTTP標準に違反しているため、完全なコンプライアンスが必要な場合は使用するべきではありません。
code: pyhton
@auth.error_handler
def unauthorized():
return make_response(jsonify({'error': 'Unauthorized access'}), 403)
Flask-JWTによる認証保護
Flask-HTTPAuth はWebアプリケーションを簡単にパスワード認証で保護することができますが、毎回ユーザ名とパスワードが送信されることが問題となります。
そこでJWT(JSON Web Token)ベース認証を使うようにします。JWT認証ではユーザ名とパスワードを送信して認証されると有効期限付きのアクセストークンを返します。クライアントは次回以降はこのアクセストークンを使ってリクエストするようにします。これにより、ユーザ情報の盗聴を困難にすることができます。
Flask-HTTPAuth でもJWTベース認証ができるのですが、ログイン認証後に’トークンを渡すことが難しい欠点があります。
そこで、Flask-JWT を使ってみましょう。
code: python
from flask import Flask, jsonify, make_response
from flask_jwt import JWT, jwt_required
from werkzeug.security import safe_str_cmp
from users import users, username_table, userid_table
def authenticate(username, password):
user = username_table.get(username, None)
if user and safe_str_cmp(user.password.encode('utf-8'),
password.encode('utf-8')):
return user
def identity(payload):
return userid_table.get(user_id, None)
app = Flask(__name__)
app.config'SECRET_KEY' = '2904c118551f558ba71085d0555bc2fead56d5fe508e3fa3' jwt = JWT(app, authenticate, identity)
@app.route('/')
@jwt_required()
def index():
return {"Hello": "World"}
Flask-JWTでは@jwt_required()デコレータでビュー関数を保護します。これは、コールバック関数authenticate() と identity() を呼び出します。それぞれ、ユーザ情報もしくはNoneを返します。
何もせずにGETメソッドでアクセスすると次のようにエラーになります。
code: bash
HTTP/1.0 401 UNAUTHORIZED
Content-Type: application/json
Content-Length: 125
WWW-Authenticate: JWT realm="Login Required"
Server: Werkzeug/1.0.1 Python/3.6.10
Date: Mon, 01 Jun 2020 02:58:33 GMT
{
"description": "Request does not contain an access token",
"error": "Authorization Required",
"status_code": 401
}
POSTでメソッドでログイン情報を送信しますが、ユーザ情報に誤りがあると次のようにエラーになります。
code: bash
{
"description": "Invalid credentials",
"error": "Bad Request",
"status_code": 401
}
ユーザ情報で正常にログイン認証されると有効期限付きのトークンが返されます。
code: bash
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1OTA5ODA0NzMsImlhdCI6MTU5MDk4MDE3MywibmJmIjoxNTkwOTgwMTczLCJpZGVudGl0eSI6MX0.qJD_7qVZOnCzW1wLfhD813zr1IbdvA1LdU4XjKkibks"
}
このトークンをHTTPヘッダ Authorization: JWT トークン としてGETメソッドでリクエストできるようになります。
code: bash
$ curl -s -H "Content-Type: application/json" -H "Authorization: JWT eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1OTA5ODA0NzMsImlhdCI6MTU5MDk4MDE3MywibmJmIjoxNTkwOTgwMTczLCJpZGVudGl0eSI6MX0.qJD_7qVZOnCzW1wLfhD813zr1IbdvA1LdU4XjKkibks" -X GET http://127.0.0.1:8080/ {
"Hello": "World"
}