#注意
つい最近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(シングルページアプリケーション)にするためです。
ヒストリーモードはオフのままにしておき、linterは適当に「Standard config」を選んで作成します。
プリセットを保存するか聞かれるので念のため保存しておきます。
以下の画面で「タスクを実行」→「アプリを開く」を押しページを見てみましょう。
おそらく以下の画面になるかと思います。
##プラグインと依存パッケージのインストール
「プラグイン」→「プラグインを追加する」より「vue-cli-lugin-vuetify」をインストールします。
vuetifyはマテリアルデザインのUIフレームワークです。
依存パッケージも同じように「axios」と「js-cookie」をインストールします。
axiosはHTTPクライアント、js-cookieはクッキーを簡単に扱えるようにするものです。
インストールが終わったら以下のようになっているか確認してください。
多分なってないので「lint」→「タスクの実行」をしてください。
##実装
これで準備は終わったのでコンポーネントを作っていきます。
基本的なことはググってください。
ちなみに僕はわかってないです。
先にsrc/components/HelloWorld.vue
とsrc/views
を削除しときます。
##コンポーネント
面倒くさいのでSPA全体をカードを使ったレイアウトにします。
<router-view />
から他のファイルを読み込んで表示するのでまあこんな感じ。
<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つのファイルを作成します。
├── NotFound.vue
├── signin.vue
├── signup.vue
└── user.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
<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
<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
<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>
##ルーティング
ページ遷移ができるようにルーティングを定義します。
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に飛ばされるかを確認しましょう。
##非同期通信
axiosを使ってAPIサーバへの通信部分を作ります。
<script>
部分のみ書き換えてください。
パスワードをハッシュ化してサーバに送ります。
意味あるんですかね。
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
<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
<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にテーブルを作成します。
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を起動するようにします。
デバッグはなんとなくオンです。
#!/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の操作はよくわからなかったのでこうしてますが、楽な方法がありそうです。
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
モジュールを読み込みやすくするファイル?
というかブループリントの場合は必須なのかな...
from flask import Blueprint
auth = Blueprint("auth", __name__)
test = Blueprint("test", __name__)
肝心のAPI部分です。
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
きちんと分割できてるか確認するファイルです。
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を使ってないんですかね。