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関数ではいちいちセッションからユーザ情報の復元はしません．


> [ログイン時のソースコードはこのへん](https://github.com/django/django/blob/master/django/contrib/auth/__init__.py#L87)
> [セッションからのユーザ復元のロジックはこのへん](https://github.com/django/django/blob/master/django/contrib/auth/__init__.py#L174)

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

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

> 認証の仕組み詳しくは[このへん](https://developer.mozilla.org/ja/docs/Web/HTTP/Authentication)とか[RFC7235](https://tools.ietf.org/html/rfc7235)とか見てください．

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


# 認証用ライブラリ（JWT）のインストールと設定

ソース: `6de7f42` → `9fd2e57`

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のライブラリ](https://github.com/GetBlimp/django-rest-framework-jwt)が提供されているので，pipでインストールします．
ついでに `requirements.txt` と `constraints.txt` も更新します．

```bash
$ 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
```

[ライブラリのドキュメント](http://getblimp.github.io/django-rest-framework-jwt/)の通り設定していきます．
まずsettings.pyを修正し，REST_FRAMEWORK設定の認証クラスに
**rest_framework_jwt.authentication.JSONWebTokenAuthentication** を追加します．

```python: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/` として設定します．

```python:tutorial/urls.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/` をブラウザで開いてみましょう．
ここで `username` と `password` を入力し，POSTしたらtokenが発行されます．

![Kobito.dx4FFZ.png](https://qiita-image-store.s3.amazonaws.com/0/58048/6bb4b772-4008-be98-c250-d5b5f6d2d9d8.png "Kobito.dx4FFZ.png")

# ログイン画面の追加

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

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

ソース: `9fd2e57` → `0b0dc4a`

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

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

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

次に[vue-router](https://router.vuejs.org/ja/api/options.html)の設定を修正し，URLにLoginコンポーネントを割り当てます．

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

```javascript: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](https://qiita-image-store.s3.amazonaws.com/0/58048/b551f09b-0588-0e74-1197-71cf2f6c59a6.png "Kobito.4Vc45Y.png")


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

ソース: `0b0dc4a` → `e66c3a8`

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

使用するForm要素は[このへん](https://vuetifyjs.com/ja/components/forms)を参考に．

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

```javascript: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](https://qiita-image-store.s3.amazonaws.com/0/58048/9dc08b42-969f-931f-823c-38eacbec41df.png "Kobito.RcPWzF.png")

## ログイン用API追加

ソース: `e66c3a8` → `d967ed4`

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

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

```javascript: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` で先ほどのモジュールを呼び出せるようにします．

```javascript: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サーバからの戻り値をコンソールに表示してみます．

```javascript: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](https://qiita-image-store.s3.amazonaws.com/0/58048/91675341-af68-17d8-43a2-517b31c56b40.png "Kobito.38nOWR.png")

## 入力フォーム修正

ソース: `d967ed4` → `37e6f63`

今のままだと入力したパスワードがそのまま見えているので，[このへんのサンプルを参考に](https://vuetifyjs.com/ja/components/text-fields#example-password)
パスワードの表示をON/OFFできるようにし，
入力がない場合やID/パスワードを間違えた時の処理を追加しましょう．

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

```javascript: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を入力してください'`という文字列を返す無名関数**
になっています．

```javascript
function (v) {
  return !!v || 'ログインIDを入力してください'
}
```
の省略系です．
returnの部分は式になっていて，`||`は[論理OR演算子です](https://msdn.microsoft.com/ja-jp/library/xxxekd04(v=vs.94).aspx)．

> [論理演算子の評価についてはこちらを参考に](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Logical_Operators)．
参考Qiita記事：[JavaScriptの「&&」「||」について盛大に勘違いをしていた件](https://qiita.com/Imamotty/items/bc659569239379dded55)


`!!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オブジェクト](https://github.com/axios/axios/blob/master/lib/core/createError.js)は **response.data** でサーバからのレスポンスを参照できます．
djangoRESTframeworkでは，入力されたフィールド名をkeyとする配列でエラー内容が返されます．
どの入力フィールドとも関係ないエラーの場合，`nonFieldErrors` というkeyの配列でエラー内容が返されます．
今回はとりあえず `nonFieldErrors` をvueが持っている同名の変数に代入させています．

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

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

ソース: `37e6f63` → `b90f807`

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

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

```javascript: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](https://qiita-image-store.s3.amazonaws.com/0/58048/4d56b1f3-73b3-23ac-5d7c-d5fe3c8c3b4a.png "Kobito.l3Np1K.png")

ログイン後
![Kobito.ngCbnU.png](https://qiita-image-store.s3.amazonaws.com/0/58048/6f93058b-e4ce-5d5b-a16a-076e862c1e95.png "Kobito.ngCbnU.png")

--

次はvuexを使ってログイン状態の確認や復元を行えるようにします．
→ [チュートリアル6へ](https://qiita.com/maisuto/items/b867d0f1dce3ba9f2169)

→ [チュートリアルまとめ](http://qiita.com/maisuto/items/17653f344d1f64afa019)