JWT認証
Vue.jsとFlaskを使ったフルスタックWeb開発に関するチュートリアルシリーズの第6回目へようこそ。今回は、JSON Web Token (JWT) 認証を使用する方法について説明します。
この投稿のコードは、私のGitHubアカウントのSixthPostというブランチで見ることができます。
シリーズ内容
- VueJSの紹介と概要
- Vueルーターの操作
- Vuexによる状態管理
- FlaskによるRESTful API
- REST APIによるAJAXの統合
- JWT認証(あなたはここにいます)
- 仮想プライベートサーバーへの展開
JWT認証の基本的な紹介
このシリーズの他の投稿と同様に、私はJWTがどのように機能するかの理論について重要な詳細に入ることはありません。その代わりに、私は実用的なアプローチを取り、FlaskとVue.jsの中で関心のある技術を使用して、その実装の詳細を示す予定です。もしJWTについてより深く理解したいのであれば、StackAbuseにあるScott Robinsonの素晴らしい投稿を参照してください。
基本的な意味において、JWTは2つのシステム間で情報を伝達するために使われる符号化されたJSONオブジェクトで、ヘッダー、ペイロード、そして[HEADER]. [PAYLOAD].[SIGNATURE]
という形式の署名からなり、全てはHTTPヘッダーに “Authorization.JWT” として含まれる。Bearer [HEADER].[PAYLOAD].[SIGNATURE]」という形でHTTPヘッダーに含まれる。このプロセスは、クライアント(要求システム)がサーバー(希望するリソースを持つサービス)と認証することから始まり、サーバーは特定の時間だけ有効なJWTを生成します。サーバーはこれを署名・符号化されたトークンとして返し、クライアントはこれを保存して後の通信で検証用に使用します。
JWT認証は、本連載で紹介するようなSPAアプリケーションで非常に有効であり、これを実装する開発者の間で大きな人気を博しています。
Flask RESTful APIにおけるJWT認証の実装
Flask側では、PythonパッケージのPyJWTを使って、JWTの作成、パース、検証に関するいくつかの特殊性を処理する予定です。
(venv) $ pip install PyJWT
PyJWTパッケージをインストールしたら、Flaskアプリケーションで認証と検証に必要な部分を実装することに移ります。まず始めに、アプリケーションに新しい登録ユーザーを作成する機能を持たせます。このアプリケーションの他の全てのクラスと同様に、User
クラスはmodels.pyモジュールに存在することになります。
最初に行うべきことは、いくつかの関数、 generate_password_hash
と check_password_hash
を、werkzeugパッケージの security
モジュールからインポートすることです。このパッケージはFlaskに自動的に付属しているので、インストールする必要はありません。
"""
models.py
- Data classes for the surveyapi application
"""
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
db = SQLAlchemy()
このクラスは、以前の記事で定義したものと同じように、 SQLAlchemy の Model
クラスを継承しています。この User
クラスは、 id
という自動生成される整数の主キーフィールドと、 email
と password
という2つの文字列フィールドを持ち、email は一意であるように設定されていることが必要です。また、このクラスに relationship
フィールドを追加して、ユーザーが作成する可能性のあるアンケートを関連付けます。一方、Survey
クラスには creator_id
という外部キーを追加して、ユーザーが作成したアンケートに関連付けるようにしました。
新しい User
オブジェクトのインスタンスを作成するときにパスワードをハッシュできるように、 __init__(...)
メソッドをオーバーライドしています。その後、クラスメソッド authenticate
を与えて、電子メールでユーザーに問い合わせを行い、与えられたパスワードハッシュがデータベースに保存されているものと一致するかどうかをチェックします。もし一致すれば、認証されたユーザーを返します。最後に、ユーザーオブジェクトをシリアライズするための to_dict()
メソッドを追加しました。
"""
models.py
- Data classes for the surveyapi application
"""
## omitting imports and what not
#
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False)
password = db.Column(db.String(255), nullable=False)
surveys = db.relationship('Survey', backref="creator", lazy=False)
def __init__(self, email, password):
self.email = email
self.password = generate_password_hash(password, method='sha256')
@classmethod
def authenticate(cls, **kwargs):
email = kwargs.get('email')
password = kwargs.get('password')
if not email or not password:
return None
user = cls.query.filter_by(email=email).first()
if not user or not check_password_hash(user.password, password):
return None
return user
def to_dict(self):
return dict(id=self.id, email=self.email)
class Survey(db.Model):
__tablename__ = 'surveys'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
questions = db.relationship('Question', backref="survey", lazy=False)
creator_id = db.Column(db.Integer, db.ForeignKey('users.id'))
def to_dict(self):
return dict(id=self.id,
name=self.name,
created_at=self.created_at.strftime('%Y-%m-%d %H:%M:%S'),
questions=[question.to_dict() for question in self.questions])
次は新しいマイグレーションを生成して、データベースを更新します。これを行うために、 manage.py モジュールと同じディレクトリで、次のコマンドを実行します。
(venv) $ python manage.py db migrate
(venv) $ python manage.py db upgrade
さて、api.pyモジュールに移動して、新しいアンケートの作成を保護するための検証機能とともに、ユーザーを登録し認証する機能を実装する時が来ました。結局のところ、邪悪なWebボットやその他の悪質なアクターが、私の素晴らしいアンケートアプリを汚染することは避けたいのです。
まず始めに、models.pyモジュールのインポートリストに、api.pyモジュールの一番上にある User
クラスを追加します。その間に、後で使用する他のインポートを追加します。
"""
api.py
- provides the API endpoints for consuming and producing
REST requests and responses
"""
from functools import wraps
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request, current_app
import jwt
from .models import db, Survey, Question, Choice, User
必要なツールのインポートが完了したので、api.pyモジュールに登録とログインのビュー関数を実装することができます。
POSTリクエストのボディに、メールとパスワードがJSONで送信されることを期待する register()
ビュー関数から始めます。ユーザーは、メールとパスワードに与えられたものを使って作成され、私はJSONレスポンスを返します(これは必ずしも最良のアプローチではありませんが、今のところうまくいきます)。
"""
api.py
- provides the API endpoints for consuming and producing
REST requests and responses
"""
## omitting inputs and other view functions
#
@api.route('/register/', methods=('POST',))
def register():
data = request.get_json()
user = User(**data)
db.session.add(user)
db.session.commit()
return jsonify(user.to_dict()), 201
かっこいい。バックエンドはアンケートを大量に作成するために新しいユーザーを作成することができるので、彼らを認証してアンケートを作成させるための機能を追加することにします。
ログイン機能は User.authenticate(...)
クラスメソッドを使用して、ユーザーの検索と認証を試みます。与えられたメールアドレスとパスワードに一致するユーザが見つかった場合、ログイン関数はJWTトークンの作成に進みます。そうでない場合は None
を返し、結果としてログイン関数は適切なHTTPステータスコード401で「認証に失敗しました」メッセージを返します。
JWTトークンは、PyJWT(jwtとして)を使用して、以下を含む辞書をエンコードすることで作成します。
- sub – jwtのサブジェクトで、この場合はユーザーのメールです。
- iat – jwtが発行された時間です。
- exp – jwtの有効期限。この例では発行から30分後です。
"""
api.py
- provides the API endpoints for consuming and producing
REST requests and responses
"""
## omitting inputs and other view functions
#
@api.route('/login/', methods=('POST',))
def login():
data = request.get_json()
user = User.authenticate(**data)
if not user:
return jsonify({ 'message': 'Invalid credentials', 'authenticated': False }), 401
token = jwt.encode({
'sub': user.email,
'iat':datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(minutes=30)},
current_app.config['SECRET_KEY'])
return jsonify({ 'token': token.decode('UTF-8') })
エンコード処理には、config.py で定義された BaseConfig
クラスの SECRET_KEY
プロパティの値を利用します。また、Flask アプリを作成したら current_app
の config プロパティに保持します。
次に、GETとPOSTの機能を分解したいと思います。現在、fetch_survey(...)
という名前の悪いビュー関数に存在しており、そのままの状態では以下のように表示されます。その代わりに、GET リクエストで “/api/surveys/” を要求したときにすべてのアンケートを取得することのみを fetch_surveys(...)
に担当させることにします。一方、アンケートの作成は、同じ URL に POST リクエストがヒットしたときに行われるので、今後は create_survey(...)
という新しい関数に任せることにします。
つまり、この…
"""
api.py
- provides the API endpoints for consuming and producing
REST requests and responses
"""
## omitting inputs and other view functions
#
@api.route('/surveys/', methods=('GET', 'POST'))
def fetch_surveys():
if request.method == 'GET':
surveys = Survey.query.all()
return jsonify([s.to_dict() for s in surveys])
elif request.method == 'POST':
data = request.get_json()
survey = Survey(name=data['name'])
questions = []
for q in data['questions']:
question = Question(text=q['question'])
question.choices = [Choice(text=c) for c in q['choices']]
questions.append(question)
survey.questions = questions
db.session.add(survey)
db.session.commit()
return jsonify(survey.to_dict()), 201
はこうなります。
"""
api.py
- provides the API endpoints for consuming and producing
REST requests and responses
"""
## omitting inputs and other view functions
#
@api.route('/surveys/', methods=('POST',))
def create_survey(current_user):
data = request.get_json()
survey = Survey(name=data['name'])
questions = []
for q in data['questions']:
question = Question(text=q['question'])
question.choices = [Choice(text=c) for c in q['choices']]
questions.append(question)
survey.questions = questions
survey.creator = current_user
db.session.add(survey)
db.session.commit()
return jsonify(survey.to_dict()), 201
@api.route('/surveys/', methods=('GET',))
def fetch_surveys():
surveys = Survey.query.all()
return jsonify([s.to_dict() for s in surveys])
本当のキーは create_survey(...)
ビュー関数を保護して、認証されたユーザだけが新しいアンケートを作成できるようにすることです。別の言い方をすれば、”/api/surveys” に対して POST リクエストが行われた場合、アプリケーションはそれが有効で認証されたユーザによって行われていることを確認する必要があります。
そこで、便利なPythonのデコレータの出番です。私は
Vue.js SPAでのJWT認証の実装
バックエンド側の認証が完了したので、次はクライアント側をVue.jsでJWT認証を実装する必要があります。まず、アプリ内のsrcディレクトリに「utils」という新しいモジュールを作成し、utilsフォルダの中にindex.jsファイルを配置します。このモジュールには、次の2つが含まれます。
-
- イベントバス。特定の事象が発生したときに、アプリケーションにメッセージを送信するために使用します。
- JWTがまだ有効かどうかを確認するための関数
この2つはこんな感じで実装しています。
"""
api.py
- provides the API endpoints for consuming and producing
REST requests and responses
"""
## omitting inputs and other view functions
#
def token_required(f):
@wraps(f)
def _verify(*args, **kwargs):
auth_headers = request.headers.get('Authorization', '').split()
invalid_msg = {
'message': 'Invalid token. Registeration and / or authentication required',
'authenticated': False
}
expired_msg = {
'message': 'Expired token. Reauthentication required.',
'authenticated': False
}
if len(auth_headers) != 2:
return jsonify(invalid_msg), 401
try:
token = auth_headers[1]
data = jwt.decode(token, current_app.config['SECRET_KEY'])
user = User.query.filter_by(email=data['sub']).first()
if not user:
raise RuntimeError('User not found')
return f(user, *args, **kwargs)
except jwt.ExpiredSignatureError:
return jsonify(expired_msg), 401 # 401 is Unauthorized HTTP status code
except (jwt.InvalidTokenError, Exception) as e:
print(e)
return jsonify(invalid_msg), 401
return _verify
変数 EventBus
は、Vueオブジェクトのインスタンスに過ぎません。Vueオブジェクトには $emit
と $on
/ $off
メソッドのペアがあり、これらはイベントの発信やイベントへの登録・解除に使用されることを利用できます。
isValid(jwt)` 関数は、JWTの情報に基づいてユーザーが認証されたかどうかを判断するために使用します。先ほどのJWTの基本的な説明で、標準的なプロパティのセットは、”[HEADER].[PAYLOAD].[SIGNATURE]”という形式でエンコードされたJSONオブジェクトに存在することを思い出してください。例えば、以下のようなJWTがあるとします。
"""
api.py
- provides the API endpoints for consuming and producing
REST requests and responses
"""
## omitting inputs and other functions
#
@api.route('/surveys/', methods=('POST',))
@token_required
def create_survey(current_user):
data = request.get_json()
survey = Survey(name=data['name'])
questions = []
for q in data['questions']:
question = Question(text=q['question'])
question.choices = [Choice(text=c) for c in q['choices']]
questions.append(question)
survey.questions = questions
survey.creator = current_user
db.session.add(survey)
db.session.commit()
return jsonify(survey.to_dict()), 201
次のJavaScriptを使えば、真ん中のボディセクションをデコードして、その内容を調べることができます。
// utils/index.js
import Vue from 'vue'
export const EventBus = new Vue()
export function isValidJwt (jwt) {
if (!jwt || jwt.split('.').length < 3) {
return false
}
const data = JSON.parse(atob(jwt.split('.')[1]))
const exp = new Date(data.exp * 1000) // JS deals with dates in milliseconds since epoch
const now = new Date()
return now < exp
}
トークン本体のコンテンツは、サブスクライバーのメールアドレスを表す sub
、タイムスタンプを秒で表した iat
、トークンの有効期限をエポックからの秒数で表した exp
です(ISO 8601 では 1970-01-01T00:00:00Z となります)。ご覧のように、私は isValidJwt(jwt)
関数の exp
値を使って、JWT が期限切れかどうかを判断しています。
次は、新規ユーザーの登録と既存ユーザーのログインのために、Flask REST API を呼び出すいくつかの新しい AJAX 関数を追加します。さらに、JWT を含むヘッダーを含めるために postNewSurvey(...)
関数を修正する必要があります。
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJleGFtcGxlQG1haWwuY29tIiwiaWF0IjoxNTIyMzI2NzMyLCJleHAiOjE1MjIzMjg1MzJ9.1n9fx0vL9GumDGatwm2vfUqQl3yZ7Kl4t5NWMvW-pgw
さて、適切な認証機能を提供するために必要な状態を管理するために、これらのものをストアで使用することができます。まず、utilsモジュールから EventBus
と isValidJwt(...)
関数を、apiモジュールから2つの新しいAJAX関数をインポートします。そして、ストアのステートオブジェクトに user
オブジェクトと jwt
トークン文字列の定義を以下のように追加します。
const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJleGFtcGxlQG1haWwuY29tIiwiaWF0IjoxNTIyMzI2NzMyLCJleHAiOjE1MjIzMjg1MzJ9.1n9fx0vL9GumDGatwm2vfUqQl3yZ7Kl4t5NWMvW-pgw'
const tokenParts = token.split('.')
const body = JSON.parse(atob(tokenParts[1]))
console.log(body) // {sub: "example@mail.com", iat: 1522326732, exp: 1522328532}
次に、先ほど定義した register(...)
や authenticate(...)
のAJAX関数を呼び出すアクションメソッドをいくつか追加する必要があります。ユーザー認証を行うメソッドを login(...)
と名付けました。これは authenticate(...)
AJAX 関数を呼び出し、新しい JWT を含む成功したレスポンスを返すと、 setJwtToken
という名前の変異をコミットし、これを変異オブジェクトに追加する必要があります。認証に失敗した場合は、プロミスチェーンに catch
メソッドを連結してエラーを捕捉し、 EventBus
を使って認証に失敗したことを通知するイベントを発生させます。
register(…)アクションメソッドは
login(…)とよく似ていますが、実際には
login(…)を利用しています。また、
submitNewSurvey(…)アクションメソッドに小さな修正を加えて、
postNewSurvey(…)` AJAX コールに追加のパラメータとして JWT トークンを渡しているところを見ています。
// api/index.js
//
// omitting stuff ... skipping to the bottom of the file
//
export function postNewSurvey (survey, jwt) {
return axios.post(`${API_URL}/surveys/`, survey, { headers: { Authorization: `Bearer: ${jwt}` } })
}
export function authenticate (userData) {
return axios.post(`${API_URL}/login/`, userData)
}
export function register (userData) {
return axios.post(`${API_URL}/register/`, userData)
}
前述のように、JWT とユーザー データを明示的に設定する新しい変異を追加する必要があります。
// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
// imports of AJAX functions will go here
import { fetchSurveys, fetchSurvey, saveSurveyResponse, postNewSurvey, authenticate, register } from '@/api'
import { isValidJwt, EventBus } from '@/utils'
Vue.use(Vuex)
const state = {
// single source of data
surveys: [],
currentSurvey: {},
user: {},
jwt: ''
}
//
// omitting all the other stuff below
//
ストアで最後にやりたいことは、アプリ内の他のいくつかの場所で呼び出されるゲッターメソッドを追加して、現在のユーザーが認証されているか否かを示すようにすることです。これは、getterの中でutilsモジュールの isValidJwt(jwt)
関数を以下のように呼び出すことで実現されます。
const actions = {
// asynchronous operations
//
// omitting the other action methods...
//
login (context, userData) {
context.commit('setUserData', { userData })
return authenticate(userData)
.then(response => context.commit('setJwtToken', { jwt: response.data }))
.catch(error => {
console.log('Error Authenticating: ', error)
EventBus.$emit('failedAuthentication', error)
})
},
register (context, userData) {
context.commit('setUserData', { userData })
return register(userData)
.then(context.dispatch('login', userData))
.catch(error => {
console.log('Error Registering: ', error)
EventBus.$emit('failedRegistering: ', error)
})
},
submitNewSurvey (context, survey) {
return postNewSurvey(survey, context.state.jwt.token)
}
}
さて、もう少しです。アプリケーションにログイン/登録ページ用の新しいVue.jsコンポーネントを追加する必要があります。Login.vueというファイルをcomponentsディレクトリに作成します。テンプレート・セクションで、2つの入力フィールドを与えます。1つはユーザー名として機能する電子メール用で、もう1つはパスワード用です。その下には2つのボタンがあり、1つは既にユーザー登録されている場合にログインするためのもので、もう1つはユーザー登録するためのものです。
const mutations = {
// isolated data mutations
//
// omitting the other mutation methods...
//
setUserData (state, payload) {
console.log('setUserData payload = ', payload)
state.userData = payload.userData
},
setJwtToken (state, payload) {
console.log('setJwtToken payload = ', payload)
localStorage.token = payload.jwt.token
state.jwt = payload.jwt
}
}
入力フィールドで v-model
を使っていることからわかるように、このコンポーネントにはユーザーに関するローカルな状態が必要です。また、errorMsg
データプロパティを追加して、登録や認証に失敗した場合に EventBus
から送信されるメッセージを保持するようにします。EventBusを利用するために、
mountedVue.js component life cycle stage で 'failedRegistering' と 'failedAuthentication' イベントを購読し、
beforeDestroystage で登録を解除しています。もう一つの注意点は、ログインボタンと登録ボタンをクリックしたときに呼び出される
@clickイベントハンドラの使用です。これらは、コンポーネントメソッドである
authenticate()と
register()` として実装する必要があります。
const getters = {
// reusable data accessors
isAuthenticated (state) {
return isValidJwt(state.jwt.token)
}
}
OK、あとは
リソース
この記事で使用されている様々なフレームワークについてもっと学びたいですか?Vue.jsの使用やPythonでのバックエンドAPIの構築について、より深く掘り下げるために以下のリソースをチェックしてみてください。
- FlaskとPythonを使ったREST APIs
- Vue.js 2 – 完全ガイド
- 究極のVue JS 2開発者コース
結論
この記事では、Vue.jsとFlaskを使用して、アンケートアプリケーションにJWT認証を実装する方法を示しました。JWTはSPAアプリケーションで認証を提供するための一般的で堅牢な方法です。この投稿を読んだ後、あなたのアプリケーションを保護するためにこれらの技術を使用することに安心感を感じていただければ幸いです。しかし、JWTがどのように、そしてなぜ機能するのかについてより深く理解するために、ScottのStackAbuseの記事を参照することをお勧めします。
また、コメントや批評も遠慮なくお寄せください。