LoginSignup
18
15

More than 5 years have passed since last update.

JWT認証をVueとFlaskで実装する

Last updated at Posted at 2019-05-01

注意

つい最近Vueに触った素人なので色々間違ってる可能性があります。
なんとなくですがVue側のソースはキャメルケース、Pythonはスネークケースで書いてます。
個人的にはスネークケースが好きなんですけどね。

概要

フロントエンドをVue CLI 3、バックエンドをFlask(Python)で実装します。
FlaskでJWTを使うにはFlask-JWTというライブラリがありますが、ファイルの分割で問題が起きたのでExtendedを使います。
(具体的には忘れた)
こちらは情報が少ないですがかなり使いやすいと思ってます。

環境

macOS Mojave 10.14.3
vue-cli 3.2.3
Python 3.6.5
MySQL 15.1

これらの導入については解説しません。

ディレクトリ構造(完成後)
login_sample/
├── api/
└── ui/

フロントエンド

とりあえず準備と簡単なログインページまで作ります。

プロジェクトの作成

まず適当な場所にプロジェクト名のフォルダを作成しておきます。

$ mkdir login_sample

以下コマンドで、ブラウザからVue CLIをGUIで操作できるようになります。

$ vue ui

作成タブの下のボタンを押しプロジェクトを作成します。
名前を「ui」にし、プロジェクトの作成場所を「login_sample」に変更してください。
また、設定は以下の写真の通りに設定してください。(よくわかってないです)
RouterはSPA(シングルページアプリケーション)にするためです。
スクリーンショット 2019-04-12 1.10.14.png
ヒストリーモードはオフのままにしておき、linterは適当に「Standard config」を選んで作成します。
プリセットを保存するか聞かれるので念のため保存しておきます。

以下の画面で「タスクを実行」→「アプリを開く」を押しページを見てみましょう。
スクリーンショット 2019-04-12 9.11.07.png
おそらく以下の画面になるかと思います。
スクリーンショット 2019-04-12 9.14.52.png

プラグインと依存パッケージのインストール

「プラグイン」→「プラグインを追加する」より「vue-cli-lugin-vuetify」をインストールします。
vuetifyはマテリアルデザインのUIフレームワークです。
依存パッケージも同じように「axios」と「js-cookie」をインストールします。
axiosはHTTPクライアント、js-cookieはクッキーを簡単に扱えるようにするものです。
インストールが終わったら以下のようになっているか確認してください。
多分なってないので「lint」→「タスクの実行」をしてください。
スクリーンショット 2019-04-12 12.06.52.png

実装

これで準備は終わったのでコンポーネントを作っていきます。
基本的なことはググってください。
ちなみに僕はわかってないです。
先にsrc/components/HelloWorld.vuesrc/viewsを削除しときます。

コンポーネント

面倒くさいのでSPA全体をカードを使ったレイアウトにします。
<router-view />から他のファイルを読み込んで表示するのでまあこんな感じ。

src/App.vue
<template>
  <v-app>
    <v-content>
      <v-layout justify-center>
        <v-flex xs12 sm7 md5 lg3>
          <v-spacer class="py-4"></v-spacer>
          <v-card class="elevation-3 pa-2 mx-2">
            <router-view />
          </v-card>
        </v-flex>
      </v-layout>
    </v-content>
  </v-app>
</template>

<script>
import './router.js'

export default {
  name: 'App'
}
</script>

App.vueに表示する以下の4つのファイルを作成します。

src/components/
├── NotFound.vue
├── signin.vue
├── signup.vue
└── user.vue

特に説明しないので頑張って理解してください。
そんな難しいことはやってないはずです...
使ってない変数とかありますが後で使います。


signin.vue
signin.vue
<template>
  <div class="signin">
    <v-snackbar color="error" v-model="snackbar" top :timeout="3000">
      {{ snackbarText }}
    </v-snackbar>

    <v-form ref="form">
      <v-card-text class="pb-2">
        <v-layout justify-start>
          <span class="display-1 primary--text mb-2">サインイン</span>
        </v-layout>

        <v-text-field
          class="mb-2"
          type="text"
          prepend-icon="person"
          v-model="username"
          :rules="nameRules"
          label="ユーザ名"
          clearable
          required
          :error="signinError"
        ></v-text-field>

        <v-text-field
          type="password"
          prepend-icon="lock"
          v-model="password"
          :append-icon="show ? 'visibility_off' : 'visibility'"
          :type="show ? 'text' : 'password'"
          @click:append="show = !show"
          :rules="passRules"
          label="パスワード"
          clearable
          required
          :error="signinError"
        ></v-text-field>
      </v-card-text>

      <v-card-actions>
        <v-layout justify-end>
          <v-btn flat color="success" @click="signup">サインアップ</v-btn>
          <v-btn type="submit" depressed color="primary" @click="signin">サインイン</v-btn>
        </v-layout>
      </v-card-actions>
    </v-form>

  </div>
</template>

<script>
import router from '../router.js'

export default {
  name: 'signin',
  data: function () {
    return {
      snackbar: false,
      snackbarText: '',
      username: '',
      password: '',
      nameRules: [v => !!v || 'ユーザ名を入力してください'],
      passRules: [v => !!v || 'パスワードを入力してください'],
      signinError: false,
      show: false
    }
  },
  methods: {
    signin: () => {
      router.push({ name: 'user' })     
    },
    signup: () => {
      router.push({ name: 'signup' })
    }
  }
}
</script>


signup.vue
signup.vue
<template>
  <div class="signup">
    <v-snackbar :color="snackbarColor" v-model="snackbar" top :timeout="3000">
      {{ snackbarText }}
    </v-snackbar>

    <v-card-text>
      <v-layout justify-start>
        <span class="display-1 primary--text mb-2">サインアップ</span>
      </v-layout>
      <v-form ref="form">
        <v-text-field
          class="mb-2"
          type="text"
          prepend-icon="person"
          v-model="username"
          :rules="[rules.required, rules.min3, rules.max20]"
          label="ユーザ名"
          clearable
          required
          :error="!username"
        ></v-text-field>

        <v-text-field
          class="mb-2"
          prepend-icon="lock"
          v-model="password"
          :append-icon="show ? 'visibility_off' : 'visibility'"
          :rules="[rules.required]"
          :type="show ? 'text' : 'password'"
          label="パスワード"
          counter
          required
          @click:append="show = !show"
          :error="!password"
        ></v-text-field>

        <v-text-field
          prepend-icon="lock"
          v-model="passwordConf"
          :rules="[rules.required]"
          type="password"
          label="確認"
          counter
          required
          :error="!passwordConf"
        ></v-text-field>
      </v-form>
    </v-card-text>

    <v-card-actions>
      <v-layout justify-end>
        <v-btn flat color="primary" @click="signin">サインイン</v-btn>
        <v-btn
          depressed
          color="success"
          @click="signup"
          type="submit"
        >
          サインアップ
        </v-btn>
      </v-layout>
    </v-card-actions>

  </div>
</template>

<script>
import router from '../router.js'

export default {
  name: 'signup',
  data: function () {
    return {
      snackbar: false,
      snackbarText: '',
      snackbarColor: '',
      username: '',
      password: '',
      passwordConf: '',
      show: false,
      rules: {
        required: v => !!v || '必須項目です',
        min3: v =>
          (v == null ? '' : v).length >= 3 || '3文字以上で入力してください',
        max20: v =>
          (v == null ? '' : v).length <= 20 || '20文字以内で入力してください'
      }
    }
  },
  methods: {
    signup: () => {
      router.push({ name: 'signin' })    
    },
    signin: () => {
      router.push({ name: 'signin' })
    }
  }
}
</script>


user.vue
user.vue
<template>
  <div class="user">

    <v-card-text>
      <v-layout justify-start>
        <span class="display-1 primary--text mb-2">ユーザ</span>
      </v-layout>
      {{ username }}
    </v-card-text>

    <v-card-actions>
      <v-layout justify-end>
        <v-btn
          depressed
          color="error"
          @click="signout"
        >
          サインアウト
        </v-btn>
      </v-layout>
    </v-card-actions>

  </div>
</template>

<script>
import router from '../router.js'

export default {
  name: 'user',
  data: function () {
    return {
      username: ''
    }
  },
  methods: {
    signout: () => {
      router.push({ name: 'signin' })
    }
  }
}
</script>


NotFound.vue
NouFound.vue
<template>
  <div class="NotFound">

    <v-card-text>
      <v-layout justify-start>
        <span class="display-1 primary--text mb-2">NotFound</span>
      </v-layout>
    </v-card-text>

    <v-card-actions>
      <v-layout justify-end>
        <v-btn
          depressed
          color="success"
          @click="signin"
        >
          サインイン
        </v-btn>
      </v-layout>
    </v-card-actions>

  </div>
</template>

<script>
import router from '../router.js'

export default {
  name: 'NotFound',
  methods: {
    signin: () => {
      router.push({ name: 'signin' })
    }
  }
}
</script>

ルーティング

ページ遷移ができるようにルーティングを定義します。

src/components/router.js
import Vue from 'vue'
import Router from 'vue-router'
import Signin from './components/signin.vue'
import Signup from './components/signup.vue'
import User from './components/user.vue'
import NotFound from './components/NotFound.vue'

Vue.use(Router)

export default new Router({
  routes: [
    { path: '/', name: 'root', component: Signin },
    { path: '/signin', name: 'signin', component: Signin },
    { path: '/signup', name: 'signup', component: Signup },
    { path: '/user', name: 'user', component: User },
    { path: '*', name: 'NotFound', component: NotFound }
  ]
})

確認①

では、ここまでちゃんと作れているか確認します。
コピペしたなら恐らく以下のようになるはずです。
ボタンを押しての移動と、存在しないURLでNouFoundに飛ばされるかを確認しましょう。
スクリーンショット 2019-04-21 22.48.57.png

非同期通信

axiosを使ってAPIサーバへの通信部分を作ります。
<script>部分のみ書き換えてください。

パスワードをハッシュ化してサーバに送ります。
意味あるんですかね。


signin.vue
signin.vue
<script>
import Cookies from 'js-cookie'
import Axios from 'axios'
import crypto from 'crypto'
import router from '../router.js'

export default {
  name: 'signin',
  data: function () {
    return {
      snackbar: false,
      snackbarText: '',
      username: '',
      password: '',
      nameRules: [v => !!v || 'ユーザ名を入力してください'],
      passRules: [v => !!v || 'パスワードを入力してください'],
      signinError: false,
      show: false
    }
  },
  methods: {
    signin: function () {
      this.snackbar = false
      this.signinError = false
      if (!this.$refs.form.validate()) {
        return
      }
      let sha256 = crypto.createHash('sha256')
      sha256.update(this.password)
      const hashPass = sha256.digest('base64')

      let axios = Axios.create({
        baseURL: 'http://localhost:5000',
        headers: {
          'Content-Type': 'application/json',
          'X-Requested-With': 'XMLHttpRequest'
        },
        responseType: 'json'
      })
      let self = this
      axios
        .post(
          '/signin',
          {
            username: self.username,
            password: hashPass
          },
          {
            validateStatus: function (status) {
              return status < 500
            }
          }
        )
        .then(res => {
          if (res.data.access_token) {
            Cookies.set('jwt_token', res.data.access_token)
            router.push({ name: 'user' })
          } else if (res.status === 401) {
            self.snackbarText = 'ユーザ名またはパスワードが違います'
            self.snackbar = true
            self.signinError = true
          } else {
            throw new Error()
          }
        })
        .catch(() => {
          self.snackbarText = 'エラーが発生しました'
          self.snackbar = true
        })
    },
    signup: () => {
      router.push({ name: 'signup' })
    }
  }
}
</script>

バリデーションして問題無かったらパスワードをハッシュ化して両方送ります。


signup.vue
signup.vue
<script>
import crypto from 'crypto'
import Axios from 'axios'
import router from '../router.js'

export default {
  name: 'signup',
  data: function () {
    return {
      snackbar: false,
      snackbarText: '',
      snackbarColor: '',
      username: '',
      password: '',
      passwordConf: '',
      show: false,
      rules: {
        required: v => !!v || '必須項目です',
        min3: v =>
          (v == null ? '' : v).length >= 3 || '3文字以上で入力してください',
        max20: v =>
          (v == null ? '' : v).length <= 20 || '20文字以内で入力してください'
      }
    }
  },
  methods: {
    signup: function () {
      this.snackbar = false
      if (!this.$refs.form.validate()) {
        return
      }
      if (this.password !== this.passwordConf) {
        return
      }

      let sha256 = crypto.createHash('sha256')
      sha256.update(this.password)
      const hashPass = sha256.digest('base64')

      let sha256Conf = crypto.createHash('sha256')
      sha256Conf.update(this.passwordConf)
      const hashPassConf = sha256Conf.digest('base64')

      let axios = Axios.create({
        baseURL: 'http://localhost:5000',
        headers: {
          'Content-Type': 'application/json',
          'X-Requested-With': 'XMLHttpRequest'
        },
        responseType: 'json'
      })
      let self = this
      axios
        .post('/signup', {
          username: self.username,
          password: hashPass,
          passwordConf: hashPassConf
        })
        .then(res => {
          self.snackbarText = '登録しました'
          self.snackbarColor = 'success'
          self.snackbar = true
          setTimeout(function () {
            router.push({ name: 'signin' })
          }, 1500)
        })
        .catch(() => {
          self.snackbarText = 'エラーが発生しました'
          self.snackbarColor = 'error'
          self.snackbar = true
        })
    },
    signin: () => {
      router.push({ name: 'signin' })
    }
  }
}
</script>

beforeMountでレンダリング(?)される前にトークンが不正でないか確認する感じです。


user.vue
user.vue
<script>
import Cookies from 'js-cookie'
import Axios from 'axios'
import router from '../router.js'

export default {
  name: 'user',
  data: function () {
    return {
      username: ''
    }
  },
  beforeMount: function () {
    let token = Cookies.get('jwt_token')
    let axios = Axios.create({
      baseURL: 'http://localhost:5000',
      headers: {
        'Content-Type': 'application/json',
        Authorization: 'Bearer ' + token
      },
      responseType: 'json'
    })
    let self = this
    axios
      .get('/protected')
      .then(res => {
        if (res.status === 200) {
          let data = res.data
          self.username = data.username
        } else {
          Cookies.remove('jwt_token')
          router.push({ name: 'signin' })
        }
      })
      .catch(() => {
        Cookies.remove('jwt_token')
        router.push({ name: 'signin' })
      })
  },
  methods: {
    signout: () => {
      Cookies.remove('jwt_token')
      router.push({ name: 'signin' })
    }
  }
}
</script>

確認②

まだ、バックエンドを作ってないので、スナックバーが表示されるかだけ確認してみましょう。

バックエンド

Flaskを使ってAPIサーバを作っていきます。
今回はログイン、新規登録、トークン確認(認証?)の3種類です。

準備

pipでライブラリをインストールし、ディレクトリ構造通りにファイルを作成します。
今回の規模だとファイルを分割する必要はないですが、拡張性を考えてこの構造にしてます。

ライブラリ一覧
flask
flask_cors
flask_jwt_extended
MySQLdb
bcrypt
ディレクトリ構造
api/
├── views/
│   ├── __init__.py
│   ├── auth.py
│   └── test.py
├── config.py
├── main.py
└── module.py

また、以下のSQL文でDBにテーブルを作成します。

SQL文
CREATE TABLE `users_data` (
  `id` int(3) NOT NULL AUTO_INCREMENT,
  `username` varchar(20) NOT NULL,
  `password` varchar(60) NOT NULL,
  `jti` varchar(36) DEFAULT NULL,
  PRIMARY KEY (`id`)
);

実装

viewsからBlueprintをインポートしてFlaskを起動するようにします。
デバッグはなんとなくオンです。

main.py
#!/usr/bin/python3.6
from config import *
from flask import Flask
from flask_jwt_extended import JWTManager
from flask_cors import CORS
from views.auth import *
from views.test import *

# API設定
app = Flask(__name__)
jwt = JWTManager(app)
CORS(app)

# views読み込み
app.register_blueprint(auth)
app.register_blueprint(test)

if __name__ == "__main__":
    app.run(debug=True)

SQL文の実行とトークンが最新のものであるか確認するモジュールです。
DBの操作はよくわからなかったのでこうしてますが、楽な方法がありそうです。

module.py
import MySQLdb
from MySQLdb.cursors import DictCursor

# SQL文送信用
def db(sql, data=None):
    db = MySQLdb.connect(host="localhost", user="root", passwd="password", db="sample", charset="utf8")
    cur = db.cursor(DictCursor)
    cur.execute(sql, data)
    rows = cur.fetchall()
    cur.close()
    db.commit()
    db.close()
    if rows:
        return rows[0]

# 古いトークンの使用禁止
def auth_jti(id, token_jti):
    sql = "SELECT display_name, username, jti FROM users_data WHERE BINARY id=%s"
    user = db(sql, [ id ])
    if token_jti == user["jti"]:
        return { "username": user["username"] }
    return False

モジュールを読み込みやすくするファイル?
というかブループリントの場合は必須なのかな...

views/__init__.py
from flask import Blueprint

auth = Blueprint("auth", __name__)
test = Blueprint("test", __name__)

肝心のAPI部分です。

views/auth.py
from . import auth
from flask import jsonify, request
from flask_jwt_extended import (
    jwt_required, create_access_token,
    get_jwt_identity, get_jti, get_raw_jwt
)
from module import db, auth_jti
import bcrypt
import time

@auth.route("/signin", methods=["POST"])
def signin():
    username = request.json.get("username", None)
    password = request.json.get("password", None)
    if not username or not password:
        return jsonify( {"message": "Format does not match"} ), 400

    sql = "SELECT id, username, password FROM users_data WHERE BINARY username=%s"
    try:
        user = db(sql, [ username ])
        if not user:
            return jsonify( {"message": "Bad username or password"} ), 401
        if bcrypt.checkpw(password.encode(), user["password"].encode()):
            sql = "UPDATE users_data SET updated_at=%s WHERE username=%s"
            timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
            db(sql, [ timestamp, username ])
        else:
            return jsonify( {"message": "Bad username or password"} ), 401
    except Exception as e:
        return jsonify( {"message": "An error occurred"} ), 500

    access_token = create_access_token(identity=user["id"])
    sql = "UPDATE users_data SET jti=%s WHERE username=%s"
    db(sql, [ get_jti(access_token), username ])
    return jsonify(access_token=access_token), 200

@auth.route("/protected", methods=["GET"])
@jwt_required
def protected():
    user = auth_jti(get_jwt_identity(), get_raw_jwt()["jti"])
    if not user:
        return jsonify( {"message": "Bad access token"} ), 401
    return jsonify( {"username": user["username"]} ), 200

@auth.route("/signup", methods=["GET", "POST"])
def signup():
    if not request.is_json:
        return jsonify( {"message": "Missing JSON in request"} ), 400
    data = request.get_json()
    username = data["username"]
    password = data["password"]
    password_conf = data["passwordConf"]

    if username and username.encode().isalnum() and password != password_conf:
        return jsonify( {"mode": "signup", "status": "error", "message": "Format does not match"} ), 400

    sql = "SELECT * FROM users_data WHERE BINARY username=%s"
    if db(sql, [ username ]):
        return jsonify( {"mode": "signup", "status": "error", "message": "This username cannot be used"} ), 400
    else:
        salt = bcrypt.gensalt(rounds=10, prefix=b"2a")
        hashed_pass = bcrypt.hashpw(password.encode(), salt).decode()
        sql = "INSERT INTO users_data (username, password) VALUE (%s, %s)"
        db(sql, [ username, hashed_pass ])
        return jsonify( {"mode": "signup", "status": "success", "message": "Completed"} ), 200

きちんと分割できてるか確認するファイルです。

views/test.py
from . import test

@test.route("/")
def root():
    return "Hello World!"

実行

以下コマンドで実行してみましょう。

$ python3.6 main.py

http://localhost:5000にアクセスしHello World!と表示されるか確認します。
それができたら実際に新規登録やサインインなどもできるはずです!

終わりに

あんま説明ないですけどなんとなく雰囲気は伝わったと信じてます。
それとFlask-JWT-Extended'sを使った日本語の記事とかってこれが初なのでは...?
使いやすいけどそもそもJWTとかFlaskを使ってないんですかね。

18
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
15