はじめに
当記事では、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にアクセスするという構成を取ります(下記の図を参照)。
注意事項
「シングルページ」では、「アプリのために管理しているバックエンドがありますか? その場合、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)」を、料金プランで「段階的層」を選択し、「作成」ボタンを押下します。
作成されたApp IDインスタンスを選択し、「認証の管理」をクリックします。
どのIDプロバイダーを有効にするかという画面で、Facebook、Google、匿名を「無効」にします(初期状態では有効です)。今回、あらかじめ作成しておいたユーザ・パスワードでのみ認証・認可を可能としたいため、Googleなどは無効にします。
「認証設定」タブをクリックし、認証後にリダイレクトさせる(リダイレクトを認める)URLとして「http://localhost:8888/*
」を追加します。
画面左側の「クラウド・ディレクトリー」->「設定」をクリックし、「ユーザーがサインアップとサインインに使用する情報」を「ユーザー名とパスワード」、「ユーザーがアプリケーションにサインアップできるようにする」を「いいえ」に設定します。
次に、認証する対象のユーザを作成します。画面左側の「クラウド・ディレクトリー」->「ユーザー」をクリックし、「ユーザーの作成」ボタンをクリックします。
ユーザーの名前、ユーザー名などを入力する画面が表示されますので、適当に入力して「保存」ボタンをクリックします。
画面左側の「アプリケーション」をクリックし、「アプリケーションの追加」ボタンをクリックします。
「名前」に任意の名前を入力し、「タイプ」を「単一ページ・アプリケーション」とし、「保存」ボタンをクリックします。
同じ流れで、今度は「タイプ」を「通常のWebアプリケーション」とし、「保存」ボタンをクリックします。
「単一ページ・アプリケーション」と「通常の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としてそれぞれの情報を組み立てた文字列を返すようにしています。
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全体の挙動を設定するための設定ファイルになります。各設定項目の意味・設定内容はコメントに記載の通りです。
# 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エンコードします)。また、username
、password
として、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
これでフロントエンドアプリ作成の準備ができました。最終的なアプリケーションのファイル構成は以下のようになります。ここからは、キーとなるファイルについてその内容を説明していきます。
フロントエンドアプリのポートはデフォルトでは8080ですが、バックエンド(Quarkus)のポートとかぶるため、vue.config.js
でフロントエンドのポートを8888に変更します。
module.exports = {
devServer: {
port: 8888,
disableHostCheck: true
}
};
App ID関連の設定値は環境変数として定義します。.env
ファイルを作成し、そこに記述します。Vue CLIで環境変数を扱う場合、項目名をVUE_APP_
で始める必要があるため、以下のような内容で定義します。
VUE_APP_APPID_CLIENT_ID=<「単一ページ・アプリケーション」のclientId>
VUE_APP_APPID_DISCOVERY_ENDPOINT=<「単一ページ・アプリケーション」のdicoveryEndpoint>
また、ローカル環境とクラウドなどのサーバ環境で、バックエンドAPIのURLが異なってきます。そのため、.env.development
にはローカル環境での設定を、.env.production
にはサーバ環境での設定を記述します。.env.development
を適用する場合、yarn serve --mode development
のように、モードを指定して起動します。
NODE_ENV=development
VUE_APP_API_SERVER_URL=http://localhost:8080
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に格納することはリスクがあるため、本番のアプリケーションでは検討が必要です。
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作成時に作成されるファイルで、各ページへのナビゲーションリンク、コンポーネント表示を定義しています。デフォルトではHome
、About
の2ページですが、ここにログインが必要となるUser
ページへのリンクを新たに追加します。
<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に出力していますが、本来は不要ですので適宜削除してください。
<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を用いてログインモーダルを表示してログインを強制します。
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をコールし、レスポンスを画面に表示します。
<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」ボタンをクリックしてログインします。
「Login」ボタンをクリックすることで以下の画面が表示されます。この画面は、App IDで提供されているログイン画面になります。Username、Passwordを入力してSign inします。
ログイン状態になったため、ユーザ名・メールアドレスが表示されます(メールアドレスは黒塗りにしています。また、スクリーンショットの取得の都合上、ユーザの姓・名はここまでのスクリーンショットのものと異なります)。ここで、「User(要ログイン)」リンクをクリックします。
既にログイン状態のため、Userページに何事もなく遷移できます。「hello」ボタンをクリックし、バックエンドAPI(/hello)を呼び出します。
バックエンドAPI(/hello)から返ってきたレスポンスデータが正常に表示されています。
以上のように、App IDを用いることで、認証・認可の処理を少ないコードで実現することができました。