Vue.jsとFlaskで作るシングルページアプリ。JWT認証

JWT認証

Vue.jsとFlaskを使ったフルスタックWeb開発に関するチュートリアルシリーズの第6回目へようこそ。今回は、JSON Web Token (JWT) 認証を使用する方法について説明します。

この投稿のコードは、私のGitHubアカウントのSixthPostというブランチで見ることができます。

シリーズ内容

  1. VueJSの紹介と概要
  2. Vueルーターの操作
  3. Vuexによる状態管理
  4. FlaskによるRESTful API
  5. REST APIによるAJAXの統合
  6. JWT認証(あなたはここにいます)
  7. 仮想プライベートサーバーへの展開

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_hashcheck_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 という自動生成される整数の主キーフィールドと、 emailpassword という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つが含まれます。

    1. イベントバス。特定の事象が発生したときに、アプリケーションにメッセージを送信するために使用します。
  1. 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モジュールから EventBusisValidJwt(...) 関数を、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の記事を参照することをお勧めします。

また、コメントや批評も遠慮なくお寄せください。

タイトルとURLをコピーしました