LoginSignup
5
3

More than 1 year has passed since last update.

NuxtJS(SSR) x TypeScriptで認証の仕組みを実装する

Last updated at Posted at 2022-01-18

当初 nuxt/auth を利用する予定だったのですが、思った通りに動かないのと、簡単な処理なのにブラックボックス化されてしまうのが気持ち悪くて、自分で実装することにしました。
※ 思った通りに動かないのは、自分の設定ミスの可能性も高いですが、、、

表題通りNuxtJS(SSR) x TypeScriptで実装していきます。
ざっくりした流れはこんな感じ。

  1. 認証APIにusernameとpasswordを送信してJWTを取得
  2. 取得したJWTはCookieに保存
  3. APIにリクエストするときはJWTをAuthorizationヘッダに設定する

準備しておくもの

認証APIを準備

JWTを取得するAPIを準備しておきます

URL

POST http://127.0.0.1:8000/api/v1/token

Content-Type: application/x-www-form-urlencodedusernamepassword を送信します。

レスポンス

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTY0MjQ4NTMxOX0.Z_cue_jF-oErAwmQC3zVp8Z7gmIWrLq7QDGXQAWpanQ",
  "token_type": "bearer"
}

認証が必要なAPIを準備

ユーザー一覧を取得するAPIを用意します。

URL

GET http://127.0.0.1:8000/api/v1/users/

認証情報(JWT)はAuthorizationヘッダに付与します。

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTY0MjQ4NTMxOX0.Z_cue_jF-oErAwmQC3zVp8Z7gmIWrLq7QDGXQAWpanQ

レスポンス

[
  {
    "username": "fuga",
    "id": 1,
    "is_superuser": true,
    "is_active": true,
    "items": [],
    "roles": []
  },
  {
    "username": "hoge",
    "id": 6,
    "is_superuser": false,
    "is_active": true,
    "items": [],
    "roles": []
  }
]

cookie-universal-nuxtをインストール

JWTはcookieに保持するので、cookie扱いやすくするためのモジュールをインストールしておきます。

npm install cookie-universal-nuxt

nuxt.config.jsmodulescookie-universal-nuxt を追加し手有効化します。

nuxt.config.js
export default {
  modules: [
    // https://github.com/microcipcip/cookie-universal/tree/master/packages/cookie-universal-nuxt
    'cookie-universal-nuxt',
  ],
}

ファイル構成

今回実装するソースの全体像はこんな感じです。

  nuxt-quickstart-ts/
  | components/
  | | Alert.vue           // ログイン失敗時のAlert表示コンポーネント
  | layouts/
  | | default.vue
  | | error.vue
  | middleware/
  | | auth.ts            // 未認証の場合にログインページにリダイレクトするミドルウェア
  | pages/
  | | users/
  | | | index.vue        // 認証しないと閲覧できないページ
  | | index.vue          // トップページ
  | | login.vue          // ログインページ
  | | logout.vue         // ログアウトページ
  | plugins/
  | | auth.ts            // 認証判定、Cookieに対してJWTの保存・取得・削除を行うプラグイン
  | | axios.ts           // リクエスト時に Authorizationヘッダを付与するaxiosのプラグイン
  | store/
  | | index.ts           // layout/default.vue用にログインステータスを保持するストア
  | nuxt.config.js

認証プラグインの実装

認証処理のコアとなるクラスを作成します。
このプラグインでは認証済み判定、Cookieに対するtokenの保存・取得・削除などを行います。
※ Cookieの操作は cookie-universal-nuxt を利用します。

plugins/auth.ts
import {NuxtCookies} from "cookie-universal-nuxt" 
import Vue from 'vue'

export default class Auth {
  // Cookieに保存するときのキー名
  private static ACCESS_TOKEN_KEY: string = "__access_token"

  // 認証済みかどうかの判定
  public static authenticated(cookie: NuxtCookies): boolean {
    let payload = this.getPayload(cookie)
    if (payload) {
      // トークンの有効期限を検証
      let exp  = parseInt(payload["exp"]);
      let now  = Math.floor((new Date()).getTime() / 1000)
      if (exp && exp > now) {
        return true
      }
    }
    return false
  }

  // CookieからJWTを削除
  public static logout(cookie: NuxtCookies): void {
    cookie.remove(this.ACCESS_TOKEN_KEY)
  }

  // CookieからJWTを取得
  public static getAccessToken(cookie: NuxtCookies): string {
    return cookie.get(this.ACCESS_TOKEN_KEY)
  }

  // JWTをCookieに保存
  public static login(cookie: NuxtCookies, token: string): void {
    return cookie.set(this.ACCESS_TOKEN_KEY, token)
  }

  // Cookieに保存されているTokenのJWTのheaderをオブジェクト形式で取得する
  public static getHeader(cookie: NuxtCookies): {[index: string]: string} | null {
    let token = this.getAccessToken(cookie)
    if (!token) return null
    let header = token.split(".")[0]
    let decoded = Buffer.from(header, "base64").toString()
    return JSON.parse(decoded)
  }

  // Cookieに保存されているTokenのJWTのpayloadをオブジェクト形式で取得する
  public static getPayload(cookie: NuxtCookies): {[index: string]: string} | null {
    let token = this.getAccessToken(cookie)
    if (!token) return null
    let payload = token.split(".")[1]
    let decoded = Buffer.from(payload, "base64").toString()
    return JSON.parse(decoded)
  }
}

ログインページの実装

Alertコンポーネント

ログインに失敗した場合にメッセージを表示したいので、Alert表示コンポーネントを作成します。

components/Alert.vue
<template>
  <v-alert v-model="alert" dismissible v-bind:type="alertType">{{ message }}</v-alert>
</template>

<script lang="ts">
import Vue from 'vue'

interface AlertData {
  alert: boolean
}
export default Vue.extend({
  props: {
    alertType: {
      type: String,
      required: true,
      validator (v) {
        return [
          'info',
          'warning',
          'error',
        ].includes(v)
      }
    },
    message: {
      type: String,
      required: true,
    },
  },

  data(): AlertData {
    return {
      alert: false,
    }
  },
  methods: {
    open() {
      this.$data.alert = true
    },
    close() {
      this.$data.alert = false
    },
  }
})
</script>

ログインページ

ログインページでは usernamepassword を入力して 認証APIをリクエストしします。
取得した認証情報(token)はCookieに保存します。
token取得失敗時は components/Alert.vue でAlertを表示します。

pages/login.vue
<template>
<div>
  <Alert ref="alert" alertType="error" :message="alertMessage"></Alert>
  <v-form ref="form" v-model="valid" lazy-validation>
   <!-- $touch: $dirtyフラグを trueにする -->
    <v-text-field
      v-model="username"
      :rules="usernameRules"
      label="Username"
      required
    ></v-text-field>
    <v-text-field
      v-model="password"
      :rules="usernameRules"
      label="Password"
      required
      type="password"
    ></v-text-field>
    <v-btn class="mr-4" @click="submit">submit</v-btn>
    <v-btn @click="clear">clear</v-btn>
  </v-form>
</div>
</template>

<script lang="ts">
import Vue from 'vue';
import Alert from '@/components/Alert.vue'
import Auth from '@/plugins/auth'

interface LoginData {
  valid: boolean
  username: string,
  password: string,
  alertMessage: string,
  usernameRules: ((v: string) => (boolean | string))[]
  passwordRules: ((v: string) => (boolean | string))[]
}

export default Vue.extend({
  components: {
    Alert: Alert
  },

  data(): LoginData {
    return {
      valid: true,
      username: '',
      password: '',
      alertMessage: '',
      usernameRules: [ // usernameのバリデーションルール
        (v: string): (boolean | string) => {return !!v || "Username is required"},
      ],
      passwordRules: [ // passwordのバリデーションルール
        (v: string): (boolean | string) => {return !!v || "Password is required"},
      ],
    }
  },

  methods: {
    submit(): void {
      // すべてのフォームのバリデーションチェックを行う
      // validate()を呼び出すには$refs.formはHTMLFormElementにキャストしないといけない
      let success = (this.$refs.form as HTMLFormElement).validate();
      if (success) {
        let form = new FormData()
        form.append("username", this.$data.username)
        form.append("password", this.$data.password)
        this.$axios.post("//127.0.0.1:8000/api/v1/token", form)
          .then(res => {
            let token = res.data.access_token
            Auth.login(this.$cookies, token) // AuthプラグインでtokenをCookieに保存
            // ひとつ前のページに戻る: https://router.vuejs.org/guide/essentials/navigation.html
            this.$router.back()
          })
          .catch(e => {
            // 失敗時はAlertを表示
            this.$data.alertMessage = e.response?.data?.detail ?? "Error..."
            (this.$refs.alert as any).open();
          })
      }
    },
    clear(): void {
      // 入力とバリデーションのリセット
      (this.$refs.form as HTMLFormElement).reset();
    }
  }
})
</script>

ログアウトページの実装

ログアウトページでは、何かを表示するといったことは行いません。
Cookieのtokenを削除したら / にリダイレクトします。

pages/logout.vue
<template>
  <div />
</template>
<script>
import Vue from 'vue'
import Auth from "@/plugins/auth"
export default Vue.extend({
  async middleware ({redirect, $cookies }) {
    Auth.logout($cookies);  // Cookieのtokenを削除
    redirect("/");
  },
})
</script> 

認証が必要なページの実装

認証判定middleware

認証が必要なページはaxiosでAPIをたたく前に、そもそもアクセスできないようにする必要があります。
※ axiosでリクエストして401 unauthorizedを受け取ってからリダイレクトだと一瞬だけページが表示されてしまいます。

そこで、ページレンダリング前に評価されるmiddlewareを利用して、認証ステータスを判定し、未認証の場合に ログインページにリダイレクトさせる仕組みを実装します。
pages配下のコンポーネントに、このmiddlewareを設定することで、未認証の時にログインページにリダイレクトさせることができます。

middleware/auth.ts
import { Middleware, Context } from "@nuxt/types"
import Auth from "@/plugins/auth"

const auth: Middleware = (context: Context) => {
  if (!Auth.authenticated(context.$cookies)) { // 未認証なら /login にリダイレクト
    return context.redirect('/login');
  }
}

export default auth; 

axiosプラグイン

middlewareでページを表示できても、APIの認証が通らなければコンテンツは表示できないので、APIリクエスト時に認証情報を持たせるプラグインを実装します。
具体的には、Cookieのtokenを Authorization ヘッダに設定するaxiosのプラグインを実装します。

plugins/axios.ts
import Auth from '@/plugins/auth'
import { Context } from '@nuxt/types'
import { AxiosError, AxiosRequestConfig } from 'axios'
export default function ({$axios, $cookies, redirect, error }: Context) {
  // リクエスト時の共通処理を定義
  // cookieにアクセストークンがあればAuthorizationヘッダに付与する
  $axios.onRequest((config: AxiosRequestConfig) => {
    // console.log(config)
    if (Auth.authenticated($cookies)) {
      let token = Auth.getAccessToken($cookies); // Cookieからtokenを取得
      config.headers.common['Authorization'] = `Bearer ${token}`;
    }
  })
} 

axiosプラグインを nuxt.config.jsplugins に追加して有効化します。

nuxt.config.js
export default {
  plugins: [
    '@/plugins/axios.ts',
  ]
}

認証が必要なページ

ユーザー一覧を表示するページを実装します。
このページには先ほどの middleware/auth.ts を設定し、tokenがCookieに存在しない状況でアクセスできないようにします。
APIリクエストは plugins/axios.ts によってCookieのtokenが自動でヘッダに設定されます。

pages/users/index.vue
<template>
<div>
  <v-simple-table>
    <template v-slot:default>
      <thead>
        <tr>
          <th class="text-left">Id</th>
          <th class="text-left">Name</th>
          <th class="text-left">更新</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="user in $data.users" v-bind:key="user.id">
          <td>{{ user.id }}</td>
          <td><nuxt-link v-bind:to="`/users/${user.id}`">{{ user.username }}</nuxt-link></td>
          <td><v-btn v-bind:to="`/users/${user.id}/edit`">edit</v-btn></td>
        </tr>
      </tbody>
    </template>
  </v-simple-table>
  <div class="mt-3">
    <v-container>
      <v-row>
        <v-col>
          <v-btn block color="primary" to="/users/create">Create</v-btn>
        </v-col>
      </v-row>
    </v-container>
  </div>
</div>
</template>

<script lang="ts">
import {Context} from '@nuxt/types'
import { AxiosError, AxiosResponse } from 'axios'
interface User {
  id: number,
  username: string,
  is_superuser: boolean,
  is_active: boolean,
  items: {[index: string]: (any)}
  roles: {[index: string]: (any)}
}
interface UsersData {
  users: User[]
}

export default {
  middleware: ['auth'], // middleware/auth.tsで未認証時にログインページにリダイレクトします
  data(): UsersData {
    return {
      users: []
    }
  },
  async asyncData(context: Context) {
    // plugins/axios.tsによって、tokenが存在する場合は Authorization ヘッダを付与してリクエストします。
    return context.$axios.get("http://127.0.0.1:8000/api/v1/users/")
      .then((res: AxiosResponse)=> {
        return {users: res.data}
      })
      .catch((e: AxiosError) => {
        context.error({
          statusCode: e.response?.status ?? 500,
          message: e.response?.statusText ?? "Internal Server Error",
        })
      })
  },
}
</script>

app-barにログイン・ログアウトボタンを実装

レイアウト

ログイン時にはログアウトボタンを表示し、ログアウト時はログインボタンを表示します。
layout/default.vue は、ページ遷移時に methods が評価されないため、 store にログイン情報をキャッシュし、 middleware でその情報を書き換えます。
middleware はページ遷移時に毎回評価される

layout/default.vue
<template>
  <v-app dark>
    <v-navigation-drawer
      v-model="drawer"
      :mini-variant="miniVariant"
      :clipped="clipped"
      fixed
      app
    >
      <v-list>
        <v-list-item
          v-for="(item, i) in items"
          :key="i"
          :to="item.to"
          router
          exact
        >
          <v-list-item-action>
            <v-icon>{{ item.icon }}</v-icon>
          </v-list-item-action>
          <v-list-item-content>
            <v-list-item-title v-text="item.title" />
          </v-list-item-content>
        </v-list-item>
      </v-list>
    </v-navigation-drawer>
    <v-app-bar
      :clipped-left="clipped"
      fixed
      app
    >
      <v-app-bar-nav-icon @click.stop="drawer = !drawer" />
      <v-toolbar-title v-text="title" />
      <v-spacer />
      <!-- ログイン・ログアウトボタン ここから -->
      <v-btn text to="/logout" v-if="$store.state.authenticated">
        <v-icon>mdi-logout</v-icon>
        logout
      </v-btn>
      <v-btn text color="primary" to="/login" v-else>
        <v-icon>mdi-login</v-icon>
        login
      </v-btn>
      <!-- ログイン・ログアウトボタン ここまで -->
    </v-app-bar>
    <v-main>
      <v-container>
        <Nuxt />
      </v-container>
    </v-main>
    <v-footer
      :absolute="!fixed"
      app
    >
      <span>&copy; {{ new Date().getFullYear() }}</span>
    </v-footer>
  </v-app>
</template>

<script lang="ts">
import Vue from 'vue'
import Auth from "@/plugins/auth"
export default Vue.extend({
  async middleware ({store, $cookies }) {
    // methodやcomputedはページ遷移時に評価されないのでmiddlewareでstoreの認証ステータスを更新する
    // https://nuxtjs.org/docs/concepts/views
    let authenticated = Auth.authenticated($cookies)
    store.commit("setAuthenticated", authenticated)
  },
  data () {
    return {
      clipped: false,
      drawer: false,
      fixed: false,
      items: [
        {
          icon: 'mdi-home',
          title: 'Home',
          to: '/'
        },
        {
          icon: 'mdi-home',
          title: 'Users',
          to: '/users/'
        }
      ],
      miniVariant: false,
      title: 'QuickStart',
    }
  },
})
</script>

ログイン情報を保持するストア

store/index.ts
import { ActionTree, MutationTree } from 'vuex'

export const state = () => ({
  authenticated: false
})

export type RootState = ReturnType<typeof state>

export const mutations: MutationTree<RootState> = {
  setAuthenticated(state, authenticated: boolean) {
    state.authenticated = authenticated
  },
}
5
3
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
5
3