LoginSignup
4
1

More than 1 year has passed since last update.

IBM Cloud App ID+Vue.js 3+Quarkusで、認証付きSPAを作る

Last updated at Posted at 2021-09-07

はじめに

当記事では、IBM Cloudにおける認証・認可サービスである「App ID」を利用し、Vue.js 3をフロントエンド、QuarkusによるREST APIをバックエンドとした認証付きSPAを作成します。

凝った認証・認可の仕組みを自前で作成することは非常に難しく、専用のミドルウェアを導入するか、Auth0やFirebase Authenticationといったインターネット上のサービスを利用する方が容易であり、かつ、セキュアに実装することが可能です。

App IDもAuth0などと同様のサービスで、少ないコードでWebアプリやAPIに認証・認可を追加することが可能になっています。App ID上に独自のユーザ(メールアドレス、ユーザ名、パスワード)を準備・利用することもできますし、GoogleやFacebookによる認証を利用することもできます。ログイン画面を自分で作成する必要は無く、App IDが提供してくれます(ログイン画面のカスタマイズも可能)。また、クライアント側、サーバ側それぞれのSDKが提供されており、実装も容易となっています。

App IDでのリソースの保護方式は、認証フロー別に「シングルページ」、「Web」、「バックエンド」などがあります。また、Kubernetes環境であれば、アプリケーションに組み込まずに、「Ingress」や「Istio」でリソースを保護することも可能になってます。

当記事では、Vue.js 3で作成したSPAを「シングルページ」で保護し、SPAの認証・認可で取得したアクセストークンを用いて、「バックエンド」で保護されたQuarkus製のREST APIにアクセスするという構成を取ります(下記の図を参照)。

image.png

注意事項

シングルページ」では、「アプリのために管理しているバックエンドがありますか? その場合、SPA のフローは適していません。 Web アプリのフローを試してください。」と記載されており、上記のようにSPAで取得したアクセストークンを利用してAPIにアクセスすることは推奨されていないと読み取れます。

当初は「Web」方式(OpenID Connect 認可コードフロー)での実装を試みたのですが、Vue + Quarkusでの実現方法が不明で、サーバ側のSDKも提供されていなかったため、上記の構成を取っています。

また、後述しますが、App ID上にアプリケーションを2つ(SPA、通常のWebアプリ)を定義するという変則的な手段を取っているため、ベストな方法ではないかもしれません。

前提環境

  • Maven 3.8.1
  • Quarkus 2.2.1
  • Node.js 12.14.1
  • npm 6.13.4
  • Vue CLI 4.5.13
  • Vue.js 3.x

App IDのインスタンス作成、初期設定

App IDのインスタンス作成ページにアクセスします。

ロケーションで「東京(jp-tok)」を、料金プランで「段階的層」を選択し、「作成」ボタンを押下します。
image.png

作成されたApp IDインスタンスを選択し、「認証の管理」をクリックします。
どのIDプロバイダーを有効にするかという画面で、Facebook、Google、匿名を「無効」にします(初期状態では有効です)。今回、あらかじめ作成しておいたユーザ・パスワードでのみ認証・認可を可能としたいため、Googleなどは無効にします。
image.png

「認証設定」タブをクリックし、認証後にリダイレクトさせる(リダイレクトを認める)URLとして「http://localhost:8888/*」を追加します。
image.png

画面左側の「クラウド・ディレクトリー」->「設定」をクリックし、「ユーザーがサインアップとサインインに使用する情報」を「ユーザー名とパスワード」、「ユーザーがアプリケーションにサインアップできるようにする」を「いいえ」に設定します。
image.png

次に、認証する対象のユーザを作成します。画面左側の「クラウド・ディレクトリー」->「ユーザー」をクリックし、「ユーザーの作成」ボタンをクリックします。
image.png

ユーザーの名前、ユーザー名などを入力する画面が表示されますので、適当に入力して「保存」ボタンをクリックします。
image.png

画面左側の「アプリケーション」をクリックし、「アプリケーションの追加」ボタンをクリックします。
image.png

「名前」に任意の名前を入力し、「タイプ」を「単一ページ・アプリケーション」とし、「保存」ボタンをクリックします。
image.png

同じ流れで、今度は「タイプ」を「通常のWebアプリケーション」とし、「保存」ボタンをクリックします。
image.png

「単一ページ・アプリケーション」と「通常のWebアプリケーション」の2つのアプリケーションを作成した後、画面に表示された各アプリケーションを展開し、以下の値をメモしておきます(後ほど使います)。

  • 単一ページ・アプリケーション

    • clientId
    • dicoveryEndpoint
  • 通常のWebアプリケーション

    • clientId
    • tenantId
    • secret
    • oAuthServerUrl

バックエンドAPI(Quarkus)の作成

次にQuarkusでバックエンド(サーバ側)のAPIを作成していきます。

まず以下のコマンドを実行し、APIの雛形を作成します。エクステンションとして、OpenID Connectを扱うための"oidc"を含めます。

mvn io.quarkus.platform:quarkus-maven-plugin:2.2.1.Final:create \
    -DprojectGroupId=org.appidsample \
    -DprojectArtifactId=appid-sample-backend \
    -Dextensions="resteasy,oidc,resteasy-jackson" \
    -DnoExamples

appid-sample-backendというディレクトリが作成され、その下にQuarkusのプロジェクト構造・設定ファイルなどが配置されます。その中のGreetingResourceクラスを編集します。

このクラスは、/helloエンドポイントを提供するAPIになります。APIはApp IDにより保護され、適切なアクセストークンが付与されていなければ401 Unauthorizedを返すようにします(このクラスではその設定は無く、後述の設定ファイルで設定します)。

適切なアクセストークンが付与されてAPIが呼び出された場合、JsonWebToken accessTokenにはそのアクセストークンが自動的に格納されます。また、UserInfo userInfoには、App IDで管理されているユーザ情報(ユーザ名、メールアドレスなど)が自動的に格納されます。

hello()メソッドでは、上記のアクセストークン、ユーザ情報の扱い方のサンプルを記述し、returnとしてそれぞれの情報を組み立てた文字列を返すようにしています。

appid-sample-backend/src/main/java/org/appidsample/GreetingResource.java
package org.appidsample;

import io.quarkus.oidc.UserInfo;
import org.eclipse.microprofile.jwt.JsonWebToken;

import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import java.util.Date;

@Path("/hello")
public class GreetingResource {

    @Inject
    JsonWebToken accessToken;

    @Inject
    UserInfo userInfo;

    @GET
    @Produces(MediaType.TEXT_PLAIN)
    public String hello() {
        String accessTokenString = this.accessToken.getRawToken();
        String name = this.userInfo.getString("name");
        String email = this.userInfo.getString("email");
        String preferredUsername = this.userInfo.getString("preferred_username");

        Date date = new Date();

        System.out.println(date + " : " + name + ", " + email + ", " + preferredUsername + ", " + accessTokenString);

        return "Hello RESTEasy : " + date + ", " + name + ", " + email + ", " + preferredUsername;
    }
}

application.propertiesは、Quarkus全体の挙動を設定するための設定ファイルになります。各設定項目の意味・設定内容はコメントに記載の通りです。

appid-sample-backend/src/main/resources/application.properties
# OpenID ConnectサーバのURL。後述の環境変数から取得して設定
quarkus.oidc.auth-server-url=${AUTH_SERVER_URL}

# アプリケーションのclientId
quarkus.oidc.client-id=${CLIENT_ID}

# テナントID
quarkus.oidc.tenant-id=${TENANT_ID}

# アプリケーションのタイプ。web-app, service, hybridのいずれかを設定する。今回はバックエンドAPIのためserviceを設定
quarkus.oidc.application-type=service

# 前述の UserInfo userInfo にユーザ情報を自動的に設定するためには、この設定を true にする必要がある。2021/09/06時点のドキュメント(https://ja.quarkus.io/guides/security-openid-connect#user-info) では「quarkus.oidc.user-info-required=true を設定します」と誤ったプロパティ名が記載されているため注意が必要。
quarkus.oidc.authentication.user-info-required=true

# リソース保護の対象のパスを指定。値を「/*」とすることで全リソースを対象としている。
quarkus.http.auth.permission.authenticated.paths=/*

# リソース保護のポリシー。値を「authenticated」とした場合は保護の対象となり、認可されなければアクセスできない。
quarkus.http.auth.permission.authenticated.policy=authenticated

# CORSフィルターの有効・無効。true とすることで有効にしている。
quarkus.http.cors=true

# CORSで許可するオリジン。「*」にしているため全て許可(そのため無効状態と同じであるが、将来的に有効にする場合は適切な内容を設定する)。
quarkus.http.cors.origins=*

App IDのインスタンスの設定でメモしておいた値を環境変数として設定します。

export AUTH_SERVER_URL=<「通常のWebアプリケーション」のoAuthServerUrl>
export CLIENT_ID=<「通常のWebアプリケーション」のclientId>
export TENANT_ID=<「通常のWebアプリケーション」のtenantId>
export SECRET=<「通常のWebアプリケーション」のsecret>

以下のコマンドで、バックエンドAPIのアプリを起動します。

mvn clean compile quarkus:dev

バックエンドAPIへのアクセスを確認するためにアクセストークンが必要となります。以下のコマンドを実行し、App IDからアクセストークンを取得します。アクセストークンの取得は、$AUTH_SERVER_URL'/token'にPOSTリクエストを投げることで取得できます。その際の認証として、「$CLIENT_ID:$SECRET」をBase64エンコードした値を渡す必要があります($CLIENT_ID$SECRETをそれぞれBase64エンコードするのではなく、コロンで連結した値をBase64エンコードします)。また、usernamepasswordとして、App IDで作成したユーザのユーザ名、パスワードを渡します。

export access_token=$(curl -w "\n" -X POST $AUTH_SERVER_URL'/token' -H 'Authorization: Basic '`echo -n $CLIENT_ID':'$SECRET | base64` -H 'Accept: application/json' -F 'grant_type=password' -F 'username=<作成したユーザのユーザ名>' -F 'password=<作成したユーザのパスワード>' | jq --raw-output '.access_token')

アクセストークンが環境変数に設定されたので、以下のcurlコマンドを実行し、Bearer認証でバックエンドAPIにアクセスします。

curl -w "\n" -v http://localhost:8080/hello -H "Authorization: Bearer "$access_token

バックエンドAPIへのアクセスに成功した場合、以下のレスポンスが得られるはずです。

Hello RESTEasy : Mon Sep 06 11:55:21 JST 2021, <ユーザの姓・名>, <メールアドレス>, <ユーザ名>

フロントエンド(Vue)の作成

最後にフロントエンドをVueで作成していきます。まずはVue CLIでアプリケーションの雛形を作成します。

vue create appid-sample-frontend

Vue CLIの各選択肢では以下の通り選択します。Vueのバージョンは3.xを選択します。また、Vue Router、Vuexを使用します。

Vue CLI v4.5.13
? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, Babel, Router, Vuex, Linter
? Choose a version of Vue.js that you want to start the project with 3.x
? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
? Pick a linter / formatter config: Basic
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, ESLint, etc.? In package.json
? Save this as a preset for future projects? (y/N) N

作成されたアプリケーションのディレクトリに移動し、ibmcloud-appid-jsをnpmでインストールします。

cd appid-sample-frontend
npm install ibmcloud-appid-js

これでフロントエンドアプリ作成の準備ができました。最終的なアプリケーションのファイル構成は以下のようになります。ここからは、キーとなるファイルについてその内容を説明していきます。
image.png

フロントエンドアプリのポートはデフォルトでは8080ですが、バックエンド(Quarkus)のポートとかぶるため、vue.config.jsでフロントエンドのポートを8888に変更します。

appid-sample-frontend/vue.config.js
module.exports = {
    devServer: {
        port: 8888,
        disableHostCheck: true
    }
};

App ID関連の設定値は環境変数として定義します。.envファイルを作成し、そこに記述します。Vue CLIで環境変数を扱う場合、項目名をVUE_APP_で始める必要があるため、以下のような内容で定義します。

appid-sample-frontend/.env
VUE_APP_APPID_CLIENT_ID=<「単一ページ・アプリケーション」のclientId>
VUE_APP_APPID_DISCOVERY_ENDPOINT=<「単一ページ・アプリケーション」のdicoveryEndpoint>

また、ローカル環境とクラウドなどのサーバ環境で、バックエンドAPIのURLが異なってきます。そのため、.env.developmentにはローカル環境での設定を、.env.productionにはサーバ環境での設定を記述します。.env.developmentを適用する場合、yarn serve --mode developmentのように、モードを指定して起動します。

appid-sample-frontend/.env.development
NODE_ENV=development
VUE_APP_API_SERVER_URL=http://localhost:8080
appid-sample-frontend/.env.production
NODE_ENV=production
VUE_APP_API_SERVER_URL=<サーバデプロイ時のURL>

フロントエンドでログインした後、リロードしてもログイン状態を維持したいため、Vuex、および、vuex-persistedstate を使用します。npmでvuex-persistedstateをインストールします。

npm install vuex-persistedstate

Vuexを使用してユーザ情報を定義します。ログイン済みかどうか、名前、メールアドレス、アクセストークンを保持します。pluginsの定義でvuex-persistedstateを使用し、SessionStorageにデータを格納するようにします。なお、アクセストークンをSessionStorageに格納することはリスクがあるため、本番のアプリケーションでは検討が必要です。

appid-sample-frontend/src/store/index.js
import { createStore } from 'vuex'
import createPersistedState from 'vuex-persistedstate'

export default createStore({
  state: {
    user: {
      isLogin: false,
      name: "",
      email: "",
      accessToken: ""
    }
  },
  mutations: {
    login(state, user) {
      state.user = user;
    },
    logout(state) {
      state.user.isLogin = false;
      state.user.name = "";
      state.user.email = "";
      state.user.accessToken = "";
    }
  },
  actions: {
  },
  modules: {
  },
  plugins: [
    createPersistedState({
      key: 'app1',
      storage: window.sessionStorage
    })
  ]
})

次に、各ページのコンポーネントを整備していきます。App.vueはVue CLI作成時に作成されるファイルで、各ページへのナビゲーションリンク、コンポーネント表示を定義しています。デフォルトではHomeAboutの2ページですが、ここにログインが必要となるUserページへのリンクを新たに追加します。

appid-sample-frontend/src/App.vue
<template>
  <div id="nav">
    <router-link to="/">Home</router-link> |
    <router-link to="/about">About</router-link> |
    <router-link to="/user">User(要ログイン)</router-link>
  </div>
  <router-view/>
</template>
(後略)

Homeページに、フロントエンド側でのログインボタンを追加します。未ログイン状態ではログインボタンを表示し、ログイン済み状態ではログアウトボタン、ユーザ名、メールアドレスを表示します。

ログイン処理は、ibmcloud-appid-jsのApp ID用SDKでほぼ全て処理してくれるため、App IDのシングルページ・アプリのドキュメントのコードを参照し、appId.signin()関数を用いて記述します。

また、appId.init()関数に渡す設定値は、上記で定義した環境変数から取得します。

※以下のコードでは確認のためアクセストークンなどをconsoleに出力していますが、本来は不要ですので適宜削除してください。

appid-sample-frontend/src/views/Home.vue
<template>
<div id="welcome">
  <p>ようこそ。</p>
  <button id="login" v-if="!this.$store.state.user.isLogin" v-on:click="onLoginButtonClick">Login</button>
  <button id="logout" v-if="this.$store.state.user.isLogin" v-on:click="onLogoutButtonClick">Logout</button>

  <div v-if="this.$store.state.user.isLogin">
    <p>{{ this.$store.state.user.name }}</p>
    <p>{{ this.$store.state.user.email }}</p>
  </div>
</div>
</template>

<script>
import AppID from 'ibmcloud-appid-js';

const appID = new AppID();

const config = {
  clientId: process.env.VUE_APP_APPID_CLIENT_ID,
  discoveryEndpoint: process.env.VUE_APP_APPID_DISCOVERY_ENDPOINT
};

(async () => {
    try {
        await appID.init(config);
    } catch (e) {
        console.log(e);
    }
})();

export default {
  name: 'Home',
  data: () => ({
    }
  ),
  methods: {
    async onLoginButtonClick() {
      const tokens = await appID.signin();
      let userInfo = await appID.getUserInfo(tokens.accessToken);
      let name = userInfo.name;
      let email = userInfo.email;

      let accessToken = tokens.accessToken;
      let accessTokenPayload = tokens.accessTokenPayload;
      let idToken = tokens.idToken;
      let idTokenPayload = tokens.idTokenPayload;

      const user = {
        isLogin: true,
        name: name,
        email: email,
        accessToken: accessToken
      }

      this.$store.commit("login", user)

      console.log("accessToken=" + accessToken);
      console.log("accessTokenPayload=" + accessTokenPayload);
      console.log("idToken=" + idToken);
      console.log("idTokenPayload=" + idTokenPayload);

      console.log("userInfo=" + JSON.stringify(userInfo));
    },
    onLogoutButtonClick() {
      this.$store.commit("logout");
    }
  }
}

</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
div#welcome {
  margin-right: auto;
  margin-left: auto;
  text-align: center;
}
</style>

ページごとにログイン状態を判断して表示・非表示を切り替えるだけでなく、未ログイン状態で保護されたページにアクセスした場合にログインモーダルを表示させる処理をVue Routerのファイルに記述します。/userのリンクをクリックした場合に、requireAuth()関数を呼び出し、ログイン状態をチェックするようにします。未ログインの場合、先ほどと同様にibmcloud-appid-jsのApp ID用SDKを用いてログインモーダルを表示してログインを強制します。

appid-sample-frontend/src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import User from '../views/User.vue'
import store from '../store'
import AppID from 'ibmcloud-appid-js';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
  },
  {
    path: '/user',
    name: 'user',
    beforeEnter: requireAuth,
    component: User
  },
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router

const appID = new AppID();

const config = {
  clientId: process.env.VUE_APP_APPID_CLIENT_ID,
  discoveryEndpoint: process.env.VUE_APP_APPID_DISCOVERY_ENDPOINT
};

(async () => {
    try {
        await appID.init(config);
    } catch (e) {
        console.log(e);
    }
})();

async function requireAuth(to, from, next) {
  if (store.state.user.isLogin) {
    next()
  } else {
    const tokens = await appID.signin();
    let userInfo = await appID.getUserInfo(tokens.accessToken);
    let name = userInfo.name;
    let email = userInfo.email;
    let accessToken = tokens.accessToken;

    const user = {
      isLogin: true,
      name: name,
      email: email,
      accessToken: accessToken
    }

    store.commit("login", user);

    next()
  }
}

最後にUserページのコンポーネントです。このページはログイン済みの状態で遷移することを前提としているため、ユーザ名・メールアドレスを(場合分けなしで)表示します。また、バックエンドAPIを呼び出す「hello」ボタンを設置し、callHelloAPI()関数を呼び出すようにします。この関数では、保持しているユーザのアクセストークンをヘッダに指定し、axiosでバックエンドAPIをコールし、レスポンスを画面に表示します。

appid-sample-frontend/src/views/User.vue
<template>
  <div class="user">
    <h1>This is a login user page</h1>
    <h2>name: {{ this.$store.state.user.name }}</h2>
    <h2>email: {{ this.$store.state.user.email }}</h2>

    <p><button v-on:click="callHelloAPI">hello</button></p>
    <p>{{ message }}</p>
  </div>
</template>

<script>
import axios from 'axios'

export default {
  name: 'User',
  data: () => ({
    message: ""
  }),

  methods: {
    callHelloAPI() {
      axios.get(process.env.VUE_APP_API_SERVER_URL + '/hello', { headers: { Authorization: "Bearer " + this.$store.state.user.accessToken }})
        .then(response => (
          this.message = response.data
        ))
    }
  }
}
</script>

ここまでできたらフロントエンドアプリを起動します。developmentモードで起動することで、ローカル環境のバックエンドAPIを呼び出すようになります。

yarn serve --mode development

バックエンドが起動していない場合、前述のmvnコマンドで起動させます。フロントエンド・バックエンドともに起動した状態で、ブラウザで「http://localhost:8888/ 」にアクセスします。「Login」ボタンをクリックしてログインします。
image.png

「Login」ボタンをクリックすることで以下の画面が表示されます。この画面は、App IDで提供されているログイン画面になります。Username、Passwordを入力してSign inします。
image.png

ログイン状態になったため、ユーザ名・メールアドレスが表示されます(メールアドレスは黒塗りにしています。また、スクリーンショットの取得の都合上、ユーザの姓・名はここまでのスクリーンショットのものと異なります)。ここで、「User(要ログイン)」リンクをクリックします。
image.png

既にログイン状態のため、Userページに何事もなく遷移できます。「hello」ボタンをクリックし、バックエンドAPI(/hello)を呼び出します。
image.png

バックエンドAPI(/hello)から返ってきたレスポンスデータが正常に表示されています。
image.png

以上のように、App IDを用いることで、認証・認可の処理を少ないコードで実現することができました。

4
1
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
4
1