Python
Django
vue.js

Python Django チュートリアル SPA編(5)

勉強会用の資料です.
今回の記事ではユーザ認証に関する処理を追加していきます.

ソースコードはgithubで管理してます.
https://github.com/usa-mimi/tutorial-spa
今回の記事から開始する人は tutorial5-start のタグから開始してください.

認証のあれやこれや

通常のウェブアプリではセッションを用いて認証処理を行います.
djangoの場合,デフォルトで django.contrib.sessions.middleware.SessionMiddleware というミドルウェアが設定されており,
このミドルウェアが Requestインスタンスに session フィールドをセットします.

view関数で引数として受け渡される request です.

ブラウザからアクセスがあると,session_keyとして32文字のランダムな文字列を発行し,クッキーに保存させます.
2回目以降のリクエスト時に,ブラウザはこのsession_keyをサーバに送信します.
発行したsession_keyはDBの django_session テーブルに保存し管理します.
ログイン時には,サーバ側でこの session_keyのユーザはログイン済み という情報を書き込んでおきます.

ログインの判定はdjango.contrib.auth.middleware.AuthenticationMiddlewareというミドルウェアで設定されます.
このミドルウェアでは Requestインスタンスに user フィールドをセットします.
セットされる内容は LazyObject で, request.user を参照した際にセッションからユーザ情報を復元します.
LazyObjectは参照されるまでは評価されないので, request.user を使わないview関数ではいちいちセッションからユーザ情報の復元はしません.

ログイン時のソースコードはこのへん
セッションからのユーザ復元のロジックはこのへん

API(シングルページアプリケーション)の場合もこの仕組みを利用できます.
ただし,その場合,APIを叩くjsはサーバと同じホストに置く必要があります.
これはクッキーが同じホストに対してのみ有効だからです.
現状作成しているアプリでは
APIサーバは http://localhost:8000 で起動しており,
SPAサーバは http://localhost:8080 で起動しているため,セッション認証が使えません.

一般的なAPIの場合, tokenapi_key と呼ばれるランダムな文字列を渡すことで認証処理を行います.
サーバの動作としてはクッキーでsession_keyを受け取り,ログイン済みかどうかを確認するのと同じです.
ただし,上述の通りAPIの場合は送信元のホストが異なることがあるため,クッキーが使えません.
そこで,クッキーの代わりに Authorization ヘッダーを付けてリクエストを送付します.
Authorizationヘッダーの中身は <認証方式>スペース<token> の形で渡します.

e.g. ベーシック認証,トークン認証,JWT認証のヘッダ例
Basic 1234567890
token aaaaaaaa
JWT xxxxxxxxxxxxxxx

認証の仕組み詳しくはこのへんとかRFC7235とか見てください.

token の場合も api_key の場合も使い方は基本的に同じですが,
token はログインの度に新規発行され, api_key は同じものを使い続けるのが一般的です.
設計によりますが,ログイン時にapi_keyをPOSTし,tokenを受け取る,というような使い方もします.
とりあえずここでは APIのログイン処理とはAuthorizationヘッダにセットするランダムな文字列を受け取ること ということを抑えておいてください.

認証用ライブラリ(JWT)のインストールと設定

ソース: 6de7f429fd2e57

djangoRESTframeworkではauthtokenというサブアプリが用意されています.
まずはこいつを使用してもいいのですが,これは名前はtokenですが,実体はapi_keyのようなもので,
ユーザに対して1つだけランダムな文字列を発行し,DBで管理します.
この方式の場合,サーバ側で認証期限切れの設定ができません.

有効期限付きのtoken発行の練習なども兼ねて,今回はJWTを導入したいと思います.
JWTは JSON Web Token の略で,tokenの中にデータを埋め込めたり,異なるサーバ間で同じtokenを使いまわしたりできます.

詳しい説明は本家や他の人が書いてくれてるQiita記事等で.
https://jwt.io/
https://qiita.com/hypermkt/items/3f82735cabe377c375ea

djangoRESTframeworkで使えるjwtのライブラリが提供されているので,pipでインストールします.
ついでに requirements.txtconstraints.txt も更新します.

$ pip install djangorestframework-jwt
Collecting djangorestframework-jwt
  Downloading https://files.pythonhosted.org/packages/2b/cf/b3932ad3261d6332284152a00c3e3a275a653692d318acc6b2e9cf6a1ce3/djangorestframework_jwt-1.11.0-py2.py3-none-any.whl
Collecting PyJWT<2.0.0,>=1.5.2 (from djangorestframework-jwt)
  Downloading https://files.pythonhosted.org/packages/31/8f/19c302aa9a391dd1fbd249362b749021b88d40fb59af0363939a2250afed/PyJWT-1.6.1-py2.py3-none-any.whl
Installing collected packages: PyJWT, djangorestframework-jwt
Successfully installed PyJWT-1.6.1 djangorestframework-jwt-1.11.0

$ echo djangorestframework-jwt >> requirements.txt
$ pip freeze > constraints.txt

ライブラリのドキュメントの通り設定していきます.
まずsettings.pyを修正し,REST_FRAMEWORK設定の認証クラスに
rest_framework_jwt.authentication.JSONWebTokenAuthentication を追加します.

tutorial/settings.py
# =========================
# django-restframework 設定
# =========================
REST_FRAMEWORK = {
    'PAGE_SIZE': 100,
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
    'DEFAULT_RENDERER_CLASSES': (
        'djangorestframework_camel_case.render.CamelCaseJSONRenderer',
        'rest_framework.renderers.BrowsableAPIRenderer',
    ),
    'DEFAULT_PARSER_CLASSES': (
        'djangorestframework_camel_case.parser.CamelCaseJSONParser',
        'rest_framework.parsers.FormParser',
        'rest_framework.parsers.MultiPartParser'
    ),
    'DEFAULT_AUTHENTICATION_CLASSES': ( # ここから追加
        'rest_framework_jwt.authentication.JSONWebTokenAuthentication',  # インストールしたJWTライブラリ
        'rest_framework.authentication.SessionAuthentication',  # デフォルトの認証方式
        'rest_framework.authentication.BasicAuthentication'  # デフォルトの認証方式
    ),
}

次に認証用のURLを追加します.
認証用のviewは rest_framework_jwt.views.obtain_jwt_token として用意されているので,
こいつを /api/1.0/auth/ として設定します.

tutorial/url.py
from rest_framework_jwt.views import obtain_jwt_token  # 追加

...

api_urlpatterns = [
    path('auth/', obtain_jwt_token),  # 追加
    path('questions/', include(question_router.urls)),
    path('choices/<int:choice_id>/vote/', poll_views.VoteView.as_view()),
]

設定が完了したら python manage.py runserver でdjango開発サーバを起動し,
http://localhost:8000/api/1.0/auth/ をブラウザで開いてみましょう.
ここで usernamepassword を入力し,POSTしたらtokenが発行されます.

Kobito.dx4FFZ.png

ログイン画面の追加

token発行用のAPIが用意できたので今度はフロント側の修正です.

ログイン用のcomponent用意とURLの追加

ソース: 9fd2e570b0dc4a

例によってLogin用のcomponentは,まずは皮だけ用意します.

frontend/src/components/Login.vue
<template>
  <div>
    loginページ
  </div>
</template>

<script>
export default {
  name: 'Login',
}
</script>

次にvue-routerの設定を修正し,URLにLoginコンポーネントを割り当てます.

routerモードはデフォルトで hash になっています.
hashモードではURLは常に一定で,後ろに付けるハッシュによってコンポーネントの紐付けを行います.
CDN(コンテンツデリバリーネットワーク)を使う場合などにはこのモードのほうが都合がいいです.
ただ,アクセスした場合のURLが http://localhost:8080/#login のように,#の後に実際のコンポーネントが指定されることになります.
モードを history に変更することで, http://localhost:8080/login のように見覚えのある形式でURLを設定することができます.
今回の修正で,ついでにモードも 'history' にセットしています.

frontend/src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'
import PollIndex from '@/components/Poll/Index'
import Login from '@/components/Login'

Vue.use(Router)

export default new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'PollIndex',
      component: PollIndex,
    },
    {
      path: '/login',
      name: 'Login',
      component: Login,
    },
  ],
})

ここまでの修正で, http://localhost:8080/login でログイン画面が表示されるようになります.

Kobito.4Vc45Y.png

ログイン用フォームの追加

ソース: 0b0dc4ae66c3a8

続いてログイン用のid/password入力フォームを追加します.

使用するForm要素はこのへんを参考に.

とりあえず最低限の入力フィールド(ログインIDとパスワード)と,ログインボタンを追加してみます.

frontend/src/components/Login.vue
<template>
  <div>
    <v-form>
      <v-text-field
        v-model="username"
        label="ログインID"
        required></v-text-field>
      <v-text-field
        v-model="password"
        label="パスワード"
        required></v-text-field>
      <v-btn @click="submit">ログイン</v-btn>
    </v-form>
  </div>
</template>

<script>
export default {
  name: 'Login',
  data () {
    return {
      username: null,
      password: null,
    }
  },
  methods: {
    submit () {
      alert('login')
    },
  },
}
</script>

これで入力フィールド2つと,ログインボタンが完成です.
ボタンを押すと 'login!' というアラートが出ます.

Kobito.RcPWzF.png

ログイン用API追加

ソース: e66c3a8d967ed4

入力ができたので,今度はサーバへの通信です.
前回の記事で投票用のモジュールを作ったので,同じ要領で認証用のモジュールを追加します.

src/api/ ディレクトリの下に認証用のモジュールとして auth.js を追加します.

frontend/src/api/auth.js
export default function (cli) {
  return {
    login (username, password) {
      const data = {
        username,
        password,
      }
      return cli.post('auth/', data)
    },
  }
}

javascriptの辞書はkeyを省略できます.省略すると変数名がkeyになります.上の書き方は
{'username': username, 'password': password}
の省略形です.

続いて,api/index.js を修正し, $request.auth で先ほどのモジュールを呼び出せるようにします.

frontend/src/api/index.js
import auth from './auth'  // 追加
import questions from './questions'

const client = axios.create({
  baseURL: process.env.API_ENDPOINT,
})

client.auth = auth(client) // 追加
...

最後に,ログインボタンを押した時の動作を修正します.
ログインが正常に完了した際にAPIサーバからの戻り値をコンソールに表示してみます.

frontend/src/components/Login.vue
  methods: {
    submit () {
      this.$request.auth.login(this.username, this.password).then(res => {
        console.log(res.data)
      })
    },
  },

ここまでで正常時の動作がひとまず完成です.
ブラウザで開発者ツールを開き,正しいIDとパスワードを入力した時にtokenが表示されることを確認しましょう.

Kobito.38nOWR.png

入力フォーム修正

ソース: d967ed437e6f63

今のままだと入力したパスワードがそのまま見えているので,このへんのサンプルを参考に
パスワードの表示をON/OFFできるようにし,
入力がない場合やID/パスワードを間違えた時の処理を追加しましょう.

入力チェックやエラー表示を追加したコードがこちら.

frontend/src/components/Login.vue
<template>
  <div>
    <v-alert :value="nonFieldErrors.length" type="error">
      <div v-for="error in nonFieldErrors" :key="error">{{ error }}</div>
    </v-alert>
    <v-form v-model="valid">
      <v-text-field
        v-model="username"
        label="ログインID"
        :rules="usernameRules"
        required></v-text-field>
      <v-text-field
        v-model="password"
        label="パスワード"
        :append-icon="showPassword ? 'visibility' : 'visibility_off'"
        :append-icon-cb="() => (showPassword = !showPassword)"
        :type="showPassword ? 'text' : 'password'"
        :rules="passwordRules"
        required></v-text-field>
      <v-btn :disabled="!valid" @click="submit">ログイン</v-btn>
    </v-form>
  </div>
</template>

<script>
export default {
  name: 'Login',
  data () {
    return {
      valid: false,
      username: null,
      password: null,
      showPassword: false,
      nonFieldErrors: [],
      usernameRules: [
        v => !!v || 'ログインIDを入力してください',
      ],
      passwordRules: [
        v => !!v || 'パスワードを入力してください',
      ],
    }
  },
  methods: {
    submit () {
      this.nonFieldErrors = []
      this.$request.auth.login(this.username, this.password).then(res => {
        console.log(res.data)
      }, err => {
        this.nonFieldErrors = err.response.data.nonFieldErrors
      })
    },
  },
}
</script>

順番に解説.

v-text-fieldに :rules の追加

<v-text-field> には rules というプロパティを渡せます.
rulesで渡すものは チェック用の関数 もしくは チェックした結果 の配列です.
チェック用の関数 が渡された場合は入力された値をその関数に渡してその結果を見ます.
渡されたチェック用関数の結果 もしくは 最初に渡された結果そのもの
true でない場合はそれをエラー表示します.

usernameRules を見ると中身が1つの配列で,その内容は
vを受け取り,値をbooleanにしたものか,'ログインIDを入力してください'という文字列を返す無名関数
になっています.

function (v) {
  return !!v || 'ログインIDを入力してください'
}

の省略系です.
returnの部分は式になっていて,||論理OR演算子です

論理演算子の評価についてはこちらを参考に
参考Qiita記事:JavaScriptの「&&」「||」について盛大に勘違いをしていた件

!!v の部分に着目すると, v には入力された値が入っているので !v によって,
何らかの値が入っている場合は false,入っていない場合は true になります.
そのbool値をさらに ! で反転されているので,
値が入っている場合は true,入っていない場合は false になります.
OR演算では片方の値が true に評価された場合,もう片方の値を見るまでもなく結果は trueです.
従って,入力がある場合は左側の式の値(この場合は true)が返されます.
入力がない場合,右辺の式を評価します.右辺は 空ではない文字列 なので true と見なされ,文字列そのもの
(この場合は ログインIDを入力してください という文字列)が返されます.

formのバリデーション/ボタンの無効化

vuetifyでは <v-form> 内の値が変化した時に上述したrulesを利用して,バリデーション(入力チェック)が走ります.
そのとき, <v-form v-model="valid"> のように v-model で変数を渡して置くと,
全ての入力チェックが正しい場合は true,1つでもエラーがある場合は false が代入されます.

これを利用し, <v-btn :disabled="!valid"> のように,validがfalseの場合に disabled にすることで,
クライアント側での入力チェックが通るまではボタンが押せない という動作を実現することができます.

パスワードの表示/非表示切り替え

<v-text-field>type="password" のプロパティをセットすると入力タイプがパスワードになり,
入力した文字が * で表示されるようになります.
今回はサンプルを参考に, :type="showPassword ? 'text' : 'password'" としています.
:がtypeの頭についているので,渡された値を式として評価します.
ここでは三項演算子を使って,Vueコンポーネントが持っている showPassword がtrueの場合は
textという文字列,falseの場合はpasswordという文字列を返すようにしています.
これで showPassword の値を true/false に変化されることでパスワードを表示したり非表示にしたりできるようになります.

:append-icon="showPassword ? 'visibility' : 'visibility_off'" の部分は表示するiconの種類の指定です.
:typeの時のように showPassword 変数の内容によってiconの種類を変えています.

:append-icon-cb="() => (showPassword = !showPassword)" の部分がiconクリック時の動作です.
showPassword変数に,showPassword変数の否定を代入しています.
つまり,現在の showPassword がfalseならtrueに,trueならfalseに変化します.

ログイン失敗時のエラー表示

    <v-alert :value="nonFieldErrors.length" type="error">
      <div v-for="error in nonFieldErrors" :key="error">{{ error }}</div>
    </v-alert>

templateの記述は上記の部分です.
中身が配列なので <div> タグを v-for で回してます.

error時はpromiseに渡す第2引数の関数が呼ばれるので,そこにエラー時の処理を書きます.
渡されるerrorオブジェクトresponse.data でサーバからのレスポンスを参照できます.
djangoRESTframeworkでは,入力されたフィールド名をkeyとする配列でエラー内容が返されます.
どの入力フィールドとも関係ないエラーの場合,nonFieldErrors というkeyの配列でエラー内容が返されます.
今回はとりあえず nonFieldErrors をvueが持っている同名の変数に代入させています.

      this.$request.auth.login(this.username, this.password).then(res => {
        console.log(res.data)
      }, err => {
        this.nonFieldErrors = err.response.data.nonFieldErrors
      })

ログイン成功時の処理修正

ソース: 37e6f63b90f807

最後に,ログイン時の処理を直しましょう.
ログインに正常終了し,tokenを取得できた場合,
APIへアクセスするときにtokenをセットし,すでにログイン済みであることをサーバに伝えます.
tokenを送るときは上述した通り,request時に Authorization ヘッダーにtokenの種類と値をセットします.
axiosの場合はinstanceもしくはaxiosに対して headers.common に値を設定すると,
リクエスト時にセットした値が送付されるようになります.
this.$request としてaxiosのinstanceがセットされているので,こいつに対してヘッダを設定します.

ログインが完了したらログイン画面に留まる必要はないのでリダイレクト処理もいれましょう.
vue-routerのインスタンスには this.$router でアクセスできます.
このインスタンスが持っているpushメソッドを使うことで任意のページに移動させることができます.
とりあえず今回はルート(/)を直指定します.

frontend/src/components/Login.vue
      this.$request.auth.login(this.username, this.password).then(res => {
        this.$request.defaults.headers.common['Authorization'] = `JWT ${res.data.token}`
        this.$router.push('/')
      }, err => {

開発者ツールのNetworkなどで質問一覧取得API( http://localhost:8000/api/1.0/questions/ )へのアクセスを見てみましょう.
ログイン後はリクエストヘッダに Authorization が追加されていることが確認できるはずです.

ログイン前の質問一覧取得
Kobito.l3Np1K.png

ログイン後
Kobito.ngCbnU.png

--

次はvuexを使ってログイン状態の確認や復元を行えるようにします.
チュートリアル6へ

チュートリアルまとめ