当初 nuxt/auth を利用する予定だったのですが、思った通りに動かないのと、簡単な処理なのにブラックボックス化されてしまうのが気持ち悪くて、自分で実装することにしました。
※ 思った通りに動かないのは、自分の設定ミスの可能性も高いですが、、、
表題通りNuxtJS(SSR) x TypeScriptで実装していきます。
ざっくりした流れはこんな感じ。
- 認証APIにusernameとpasswordを送信してJWTを取得
- 取得したJWTはCookieに保存
- APIにリクエストするときはJWTをAuthorizationヘッダに設定する
準備しておくもの
認証APIを準備
JWTを取得するAPIを準備しておきます
URL
POST http://127.0.0.1:8000/api/v1/token
Content-Type: application/x-www-form-urlencoded
で username
と password
を送信します。
レスポンス
{
"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.js
の modules
に cookie-universal-nuxt
を追加し手有効化します。
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
を利用します。
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表示コンポーネントを作成します。
<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>
ログインページ
ログインページでは username
と password
を入力して 認証APIをリクエストしします。
取得した認証情報(token)はCookieに保存します。
token取得失敗時は components/Alert.vue
でAlertを表示します。
<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を削除したら /
にリダイレクトします。
<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を設定することで、未認証の時にログインページにリダイレクトさせることができます。
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のプラグインを実装します。
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.js
の plugins
に追加して有効化します。
export default {
plugins: [
'@/plugins/axios.ts',
]
}
認証が必要なページ
ユーザー一覧を表示するページを実装します。
このページには先ほどの middleware/auth.ts
を設定し、tokenがCookieに存在しない状況でアクセスできないようにします。
APIリクエストは plugins/axios.ts
によってCookieのtokenが自動でヘッダに設定されます。
<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
はページ遷移時に毎回評価される
<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>© {{ 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>
ログイン情報を保持するストア
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
},
}