2023年3月8日水曜日

Quasar ですべてのページにアクセスする前に特定の処理をさせる方法

Quasar ですべてのページにアクセスする前に特定の処理をさせる方法

概要

例えば認証をつけたい場合に使えます
前回のアプリケーションをベースに変更していきます

環境

  • macOS 11.7.4
  • nodejs 19.6.0
    • yarn 1.22.19
    • quasar 2.0.0
  • Python 3.10.9
    • Flask 2.2.3
    • Flask-CORS 3.0.10
    • authlib 1.2.0

axios のリクエストを管理する (src/boot/axios.js)

バックエンドとやり取りする api をアプリケーション全体で管理するようにします

  • vim src/boot/axios.js
import { boot } from 'quasar/wrappers'
import axios from 'axios'

const api = axios.create({ 
  baseURL: 'http://localhost:5000',
  withCredentials: true
})

export default boot(({ app }) => {
  app.config.globalProperties.$axios = axios
  app.config.globalProperties.$api = api
})

export { axios, api }

ルーティングの設定 (src/router/routes.js)

認証が必要なページと不要なページを設定します
認証が必要なページは meta.requiresAuth を設定します

  • vim src/router/routes.js
const routes = [
  {
    path: '/',
    component: () => import('layouts/MainLayout.vue'),
    children: [
      { path: '', component: () => import('pages/IndexPage.vue'), meta: { requiresAuth: true } }
    ]
  },
  {
    path: '/login',
    component: () => import('layouts/MainLayout.vue'),
    children: [
      { path: '', component: () => import('pages/LoginPage.vue') }
    ]
  },
  {
    path: '/:catchAll(.*)*',
    component: () => import('pages/ErrorNotFound.vue')
  }
]

export default routes

beforeEach を router に追加する (src/router/index.js)

Quasar の beforeEach という機能を使うことですべてのページに対して共通して実施したいことを定義できます

先程各ページに付与した meta.requiresAuth が有効なページの場合に認証済みかどうかをバックエンドに聞きに行くような処理を追加します

バックエンド側にはあとで /verify というエンドポイントを追加して認証が完了しているかチェックする機能を追加します

/verify でエラーが返ってきた場合は Quasar のログインページを表示します

  • vim src/router/index.js
import { route } from 'quasar/wrappers'
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
import routes from './routes'
import { api } from 'boot/axios'

export default route(function (/* { store, ssrContext } */) {
  const createHistory = process.env.SERVER
    ? createMemoryHistory
    : (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory)

  const Router = createRouter({
    scrollBehavior: () => ({ left: 0, top: 0 }),
    routes,
    history: createHistory(process.env.MODE === 'ssr' ? void 0 : process.env.VUE_ROUTER_BASE)
  })

  // ここを追加する
  Router.beforeEach((to, from, next) => {
    if (to.matched.some(record => record.meta.requiresAuth)) {
      // 認証が必要なページはバックエンドに問い合わせる
      api.get('/verify')
        .then((response) => {
          console.log(response.data)
          next()
        })
        .catch((error) => {
          console.log(error)
          next('/login')
        })
    } else {
      // 認証が不要なページはそのまま表示する
      next()
    }
  })

  return Router
})

注意事項

本当は catch したところでバックエンドのログインページに飛ばしてあげてもいいのですがそうすると無限ループになってしまうのでそうならないように認証が不要なログインページを用意してあげる必要があります

ログイン用のページ (src/pages/LoginPage.vue)

このページには認証がありません
ログイン/ログアウトボタンとログインしたあとで取得できるユーザ情報を表示しています

  • vim src/pages/LoginPage.vue
<template>
  <div>
    <h1>Login Test Page</h1>
    <div>{{ data }}</div>
    <button @click="login">Login</button>
    <button @click="logout">Logout</button>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { api } from 'boot/axios'

const data = ref('nodata')

const fetchUser = async(value) => {
  api.get('/user')
    .then((response) => {
      data.value = response.data
    })
    .catch((error) => {
      console.log(error)
    })
}

const login = async(value) => {
    window.location.href = 'http://localhost:5000/login';
}

const logout = async(value) => {
  api.get('/logout')
    .then((response) => {
      data.value = response.data
    })
    .catch((error) => {
      console.log(error)
    })
}

onMounted(() => {
  fetchUser()
})
</script>

バックエンドの処理

基本は前回と同じですが verify メソッドを追加しています
verify ではセッション(クッキー)に値が設定されているかをチェックしています

  • vim app.py
from typing import Any, Optional
from flask import (Flask,
                   url_for,
                   session,
                   redirect)
from flask_cors import CORS
from authlib.integrations.flask_client import OAuth

app = Flask(__name__)
app.secret_key = "xxxx"
app.config["ONELOGIN_CLIENT_ID"] = "xxxx"
app.config["ONELOGIN_CLIENT_SECRET"] = "xxxx"
app.config["SESSION_COOKIE_SECURE"] = True
app.config["SESSION_COOKIE_HTTPONLY"] = True
CORS(app,
     supports_credentials=True,
     max_age=300)

oauth = OAuth(app)
oauth.register(
  name="onelogin",
  server_metadata_url="https://xxxxxx.onelogin.com/oidc/2/.well-known/openid-configuration",
  client_kwargs={"scope": "openid email profile"}
)


def get_session() -> Optional[Any]:
    return session.get("user")


def has_session() -> bool:
    user = get_session()
    if user is None:
        return False
    return True


@app.route("/user")
def user():
    if not has_session():
        return "Not logged in yet."
    return f"Login successful. => {get_session()}"


@app.route("/verify")
def verify():
    if not has_session():
        return "Not logged in yet.", 400
    return "Already logged in."


@app.route("/login")
def login():
    redirect_uri = url_for("auth", _external=True)
    return oauth.onelogin.authorize_redirect(redirect_uri)


@app.route("/callback")
def auth():
    token = oauth.onelogin.authorize_access_token()
    session["user"] = token["userinfo"]
    return redirect("http://localhost:8080/#/login")


@app.route("/logout")
def logout():
    session.pop("user", None)
    return "Logout successful."

動作確認

  • quasar dev
  • pipenv run flask run

で localhost:8080/#/ にアクセスすると強制的にログインページに飛ばされるのが確認できます
そして OpenIDConnect でログイン後には localhost:8080/#/ に無事アクセスできることが確認できると思います

最後に

Quasar で全ページに対して行う共通処理を実現する方法を紹介しました
特定のページにのみ実施したい場合には meta 情報を使って beforeEach で判断するようなテクニックがあります

ログアウトするとアプリケーション内のセッションは消えますが OpenIDConnect を提供している Idp 側ではまだセッションが残っている場合があります
ログアウト時に Idp 側のセッションもログアウトにする方法があるので興味がある方は調べてみてください

参考サイト

1 件のコメント:

  1. バックエンドのレスポンスは flask.jsonify を使って JSON 形式にしたほうがいいかもしれません

    返信削除