入門: Vue.jsで認証機能を作る

## はじめに

前回、[Vue.jsとQuarkusでCRUDアプリを作った](https://qiita.com/koduki/items/9ce6ad8787b32caa38ff)ので、今回は簡単な認証機能を作ってみました。

Firabase認証とかJSON Web Signature（JWS）使うとかFIDO2だOIDCだの実際にログイン機能を作る時には選択肢は色々あると思います。
ただ、そもそも「Vue.jsで認証機能ってどう作るの？」ってところから良くわかってなかったので、今回は余計なことは除いてサーバ側は認証をスケルトンにしたトークン認証として実装しました。

## 基本的な考え方

認証機能をどう作るかはフレームワークなどにより異なります。
一般的なサーバサイドで動くWebフレームワークであればセッションで判定するでしょう。サーバ側で制御するのでJSなどクライアント側ではあまり気にしなくて良かったのですが、Vue.jsのようなSPAの場合はクライアント側にも作り込みが入ります。

Vue.jsの認証では以下が基本的な戦略になるっぽいです。

- ログインリクエストをAPIに送る
- APIがトークン等なんらかの認証情報を返す
- 受け取った情報をVuex（Vue上のストレージ層）に保存する
- Vue RouterでVuexに格納したパラメータの有無を判定し、無ければログインページなどにリダイレクトする

考え方自体はシンプルですね。
ただ、ここで少しややこしいのがVuexです。Vuexは「[Vue.jsのための状態管理パターン + ライブラリ](https://vuex.vuejs.org/ja/)」です。
FluxやReduxに連なる技術ですが、実は私の中でも今一つ良さが未だに消化し切れていません。とはいえ、お作法に則って普通に状態管理ライブラリとして使用することは出来るので、CookieやLocalStorageを直接叩く代わりにVuexを使います。

## クライアントサイド側でのログインの実装

まずはAPIなど使わずにVuex.js単体でログイン機能を作ります。

### 画面の作成

ログイン画面を実装します。

```Login.vue
<template>
    <form v-on:submit.prevent="doLogin">
        <label>User ID</label>
        <input type="text" placeholder="customer id" v-model="user.userId" />
        <label>Password</label>
        <input type="password placeholder="password" v-model="user.password" />
        <button type="submit">Sign In</button>
    </form>
</template>
<script>
export default {
  data() {
    return {
      user: {}
    };
  },
  methods: {
    doLogin() {
      this.$store.dispatch("auth", {
        userId: this.user.userId,
        userToken: 'dummy token'
      });
      this.$router.push(this.$route.query.redirect);
    }
  }
};
</script>
```

つづいて認証後にしかアクセスできないホーム画面を実装します。これはログインIDとAPIコール経由でメッセージを表示します。

```Home.vue
<template>
  <h3>Welcome {{ $store.state.userId }}</h3>
  <p>{{message}}</p>
</template>
<script>
export default {
  data() {
    return {
      message: ""
    };
  },
  created: function() {
    this.fetchHello();
  },
  methods: {
    fetchHello() {
      const uri = "http://localhost:8080/hello";
      this.axios.get(uri).then(response => {
        this.message = response.data.message;
      });
    }
  }
};
</script>
```

API側は以下のような単純にメッセージを返す実装とします。

```src/main/java/app/rs/HelloResource.java
@Path("/hello")
@Produces(MediaType.APPLICATION_JSON)
public class HelloResource {
    @GET
    public Map<String, String> hello() {
        return Map.of("message", "Hello World");
    }
}
```

次にVuexの設定をします。まずはインストールです。

```bash
$ yarn add vuex
```

### Vuexの設定

続いて`store`ディレクトリ配下にvuexの設定を作成します。

```src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

const store = new Vuex.Store({
    state: {
        userId: "",
        userToken: ""
    },
    mutations: {
        updateUser(state, user) {
            state.userId = user.userId;
            state.userToken = user.userToken;
        }
    },
    actions: {
        auth(context, user) {
            context.commit('updateUser', user);
        }
    },
    modules: {},
})

export default store
```

stateに`userId`と`userToken`を持たせています。これが状態の保存先になります。
Vuexではデータの参照は直接できますが、変更をするにはミューテーションとアクションが必要です。これによりグローバルなデータが様々な場所で変更されてトラッキングしづらいくなるという問題を抑えているのだと思います。今回は`auth`アクションを実装して認証情報を格納しています。

次にグローバルから利用できるようにmain.jsにインポートします。

```src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from '@/store'
...
new Vue({
  router,
  render: h => h(App),
  store
}).$mount('#app')
```

これで`$store.state.userId`のような形でVue.js上で利用できます。

### Vue Routerの設定

最後にVue Routerの設定をします。

```src/router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'

// compornent
import Login from '../views/Login.vue'
import Home from '../views/Home.vue'

// store
import Store from '@/store/index.js'

Vue.use(VueRouter)

const routes = [
  {
    name: 'Login',
    path: '/login',
    component: Login
  },
  {
    name: 'Home',
    path: '/',
    component: Home,
    meta: { requiresAuth: true }
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

router.beforeEach((to, from, next) => {
  if (to.matched.some(record => record.meta.requiresAuth) && !Store.state.userToken) {
    next({ path: '/login', query: { redirect: to.fullPath } });
  } else {
    next();
  }
});

export default router
```

まず、`routes`を設定する時にメタ情報として`{requiresAuth: true}`を設定しています。
続いて`router.beforeEach`を使って画面遷移のリクエストをフックしています。その中で、メタ情報が`requiresAuth:true`でかつ`userToken`がVue.jsで空の時にログインページにリダイレクトさせています。
また、ログイン後に戻って来れるようにログインページのゲットパラメータに`redirect: to.fullPath`を付与しています。

### 動作確認

これで`yarn serve`などで動かせば、クライアント側での認証のチェックが確認出来るかと思います。

## APIのログイン機能の実装と連携

JAX-RSでログインAPIを実装します。
といっても今回はリクエストを受け取ればパスワードチェックもせずに問答無用でログインが成功します。

```src/main/java/app/rs/LoginResource.java
@Path("/login")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class LoginResource {
    public static class LoginResponse {
        public final String userId;
        public final String token;

        public LoginResponse(String userId, String token) {
            this.userId = userId;
            this.token = token;
        }
    }

    @Schema(description = "Login request")
    public static class LoginRequest {
        public String userId;
        public String password;
    }

    @POST
    @Operation(
            summary = "Login",
            description = "Login with User ID and Password.")
    public LoginResponse login(LoginRequest request) {
        return new LoginResponse(request.userId, "server token");
    }
}
```

戻り値としてトークンを返しています。これは本来であればリフレッシュトークンやアクセストークンなど適切な値を返すと思いますが、今回は固定値`server token`を返します。

つづいて`Login.vue`をAPIをコールするように修正します。

```Login.vue
...
<script>
...
  methods: {
    doLogin() {
      this.axios.post(uri, this.user).then(response => {
        this.$store.dispatch("auth", {
          userId: response.data.userId,
          userToken: response.data.token
        });
        this.$router.push(this.$route.query.redirect);
      });
    }
  }
};
</script>
```

元のコードとほぼ同様です。単にaxiosでAPIをコールに変更しただけですね。
開発環境で試す時はポート番号がAPIとVue.jsで違うはずなので[CORSの設定](https://qiita.com/koduki/items/9ce6ad8787b32caa38ff#cors%E3%81%AE%E8%A8%AD%E5%AE%9A)を忘れないでください。

これでAPIの挙動に応じてVue.js側の認証を作ることができました。

## APIへの認可の実装

さて、これで問題無さそうですが、これだとAPI側に認証がありません。
そのため、以下のようにログインしてないユーザからでも応答を返してしまいます。

```bash
$ curl localhost:8080/hello
{"message":"Hello World"}
```

これではセキュリティ的に大きな問題がありますのでAPI側にトークン認証を追加します。

```src/main/java/app/rs/HelloResource.java
    @GET
    public Response hello(@HeaderParam("Authorization") String authorization) {
        if(authorization != null && authorization.equals("server token")){
            return Response
                .status(Response.Status.OK)
                .entity(Map.of("message", "Hello World"))
                .build();
        }else{
            return Response
                .status(Response.Status.UNAUTHORIZED)
                .entity("Unauthorized")
                .build();
        }
    }
```

JAX-RSの場合、実際はフィルタなどで実装するとは思いますが、今回はエンドポイントにそのまま書いてみました。
Authorizationヘッダーから値を取り出して`server token`かどうかを検証しています。トークン無しで投げると以下のように401エラーになります。

```bash
$ curl -i -H 'Authorization:non valid token' localhost:8080/hello
HTTP/1.1 401 Unauthorized
Content-Type: application/json
Content-Length: 12

Unauthorized
```

Vue.js側もトークンを利用するように修正します。ヘッダーオプションを引数に追加すればOKです。

```Home.vue
...
<script>
...
  methods: {
    fetchHello() {
      const uri = "http://localhost:8080/hello";
      this.axios.get(uri, {
        headers: { 
          "Content-Type": "application/json", 
          "Authorization": $store.state.userToken 
        },
        data: {} 
      }).then(response => {
        this.message = response.data.message;
      });
    }
  }
};
</script>
```

これでAPI側にもトークン認証が無事設定できました。なお、実際の構築時は自前で作らず適切なライブラリを使うことを強く推奨します。

## まとめ

Vue.jsとQuarkusを使って認証機能/ログイン機能を実装してみました。まあ、サーバ側はほぼダミー実装ですが、SPAにおける認証をどのように作るかは、むしろこの方が分かりやすいかと思います。

今回はトークンは固定値ですが実際はJWTなどを使うと思います。その時は[JWTはアクセストークンとして実装してリフレッシュトークンはセッション等に別途入れる](https://koduki.hatenablog.com/entry/2019/11/03/163014)という実装が必要になるかと思いますのでステートレスなJWTをセッションの代わりにするような実装は避けましょう。

Webアプリケーションの基本はCRUDと認証なので、これが作れれば後は書きたいものをガシガシ実装だけしていけば良さそうですね。
正直、分業を前提としてる仕組みなので一人や小規模で使うと古き良きPHP/ERB/JSPに比べて簡単であるとは言い難いので、今後はそこを抽象化するようなレイヤーも流行るかもしれないですね。

それでは、Happy Hacking!

## 参考

- [Vue.js 2.0 でログイン (vue-router で認証が必要な URL を定義)](https://qiita.com/takatama/items/05e9fbc7199cde4caf60)
- [Vuex とは何か？](https://vuex.vuejs.org/ja/)