ラベル Quasar の投稿を表示しています。 すべての投稿を表示
ラベル Quasar の投稿を表示しています。 すべての投稿を表示

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 側のセッションもログアウトにする方法があるので興味がある方は調べてみてください

参考サイト

2023年3月7日火曜日

QuasarでOpenIDConnect認証連携する方法

QuasarでOpenIDConnect認証連携する方法

概要

Quasar で OpenIDConnect 認証する方法を紹介します
バックエンドに Flask アプリケーションを使って連携します
今回連携する OpenIDConnect 先は Onelogin になります

環境

  • 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

Quasarプロジェクトの作成

こちらを参考に作成しましょう
error @achrinza/node-ipc@9.2.5: The engine "node" is incompatible with this module. が発生する場合は

  • yarn config set ignore-engines true

を設定しましょう

Quasar 側のログインテストページの作成

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

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

const data = ref('nodata')

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

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>

またルーティングを追加しましょう

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

  // Always leave this as last one,
  // but you can also remove it
  {
    path: '/:catchAll(.*)*',
    component: () => import('pages/ErrorNotFound.vue')
  }
]

export default routes

起動は

  • quasar dev

で行います
localhost:8080/#/login にアクセスすると OpenIDConnect のログインテスト用のページが表示されます

バックエンドの Flask アプリをまだ作成していないので動作はしません

Flask でバックエンドアプリの作成

flask-cors, authlib を使うのでインストールしましょう

  • pipenv install flask-cors authlib

今回 OpenIDConnect との連携はすべて Flask 側で行います
認証が成功して Flask 側で ID トークンを受け取ったらそれを session (cookies) に保存して SPA 側と共有します

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 = "xxx"
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"}
)


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


@app.route("/user")
def user():
    user = session.get("user")
    if user is None:
        return "Not logged in yet."
    return f"Login successful. => {user}"


@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."

起動しましょう

  • pipenv run flask run

これでバックエンドは localhost:5000 で起動します
当然ですが Quasar は localhost:8080 で起動しバックエンドは localhost:5000 で起動しているので CORS の設定が必要です

ポイント

ログインはSPA側からバックエンドにリダイレクトする

OpenIDConnet のログインページへのリダイレクトは Flask 側で行います
なので SPA 側からログインページを表示したい場合はまずバックエンドの /login URI へリダイレクトする必要があります

SPA からリダイレクトをせずに Flask から直接ログインページへリダイレクトしようとすると CORS エラーになるので注意が必要です

SPA側とバックエンド側の認証情報の共有はセッション(cookies)を使う

クッキー情報はドメインごとに管理されます
しかし今回はドメインが異なるので異なるドメイン間でクッキーを共有する必要があります

Quasar 側では withCredentials: true を設定することで axios リクエスト時にバックエンドにクッキー情報を送信できます

Flask 側では CORS(app, supports_credentials=True, max_age=300) という感じで supports_credentials=True にすることで送信された異なるドメインから送信されたクッキーを受け入れることができます

セキュリティ的な面を考慮する

今回はクッキーの Secure 属性と HTTP-Only 属性のみ有効にしています
あとは CORS 側の設定で max_age を設定して共有する有効期限を定めています

そもそもクッキーを使わない方法もあるようなのでプロダクションではセキュリティ麺を考慮した設定が実装の追加が必要になるかなと思います

最後に

Quasar に OpenIDConnect を使った認証機能を追加してみました
基本はバックエンド側のアプリが必要になると思います (調べてみると SPA オンリーでもできるっぽいですが)

SPA はモダンなのですが考えることが多いのが厄介です
そういうのも含めると普通に Web アプリケーションとして作成するのがいいのかもしれません

2022年10月17日月曜日

QuasarでAjaxリクエストを送信する方法

QuasarでAjaxリクエストを送信する方法

概要

axios を使って Quasar で外部のサイトにリクエストを送信する方法を紹介します

環境

  • macOS 11.7
  • node 18.9.0
  • quasar 2.8.2
  • axios 0.27.2

インストール

Quasarプロジェクト作成時にインストールしなかった場合は別途インストールします

  • yarn add axios

(任意)通知の有効化

$q.notify を使うので設定ファイルを編集して通知を有効にします
quasar.config.js というファイルがありその中の framework という設定箇所があるのでそこの plugins と config を変更します

plugins には「Notify」を入力し config には「notify」を追加します

  • vim quasar.config.js
return {
  framework: {
    plugins: [
      'Notify'
    ],
    config: {
      notify: { /* look at QuasarConfOptions from the API card */ }
    }
  }
}

※上記は一部抜粋

サンプルコード

  • vim src/pages/IndexPage.vue
<template>
  <div>{{ data }}</div>
</template>

<script lang="ts" setup>
import { ref } from 'vue';
import axios from 'axios';
import { useQuasar } from 'quasar'

const data = ref('nodata')
const $q = useQuasar()

const api = axios.create({ baseURL: 'https://sample.allowed.cors.site' })

api.get('/')
  .then((response) => {
    data.value = response.data
  })
  .catch(() => {
    $q.notify({
      color: 'negative',
      position: 'top',
      message: 'Loading failed',
      icon: 'report_problem'
    })
  })
</script>

ちょっと解説

リクエストは axios を使って行います
今回は GET ですが POST なども行えます
今回のサンプルだとページを読み込んだ段階で毎回コールしてしまうのでボタンを押した場合やコンテンツが更新されたタイミングでコールするようにします

アクセスするサイトは当然ですが CORS 対応されたサイトにアクセスしてください
面倒であれば localhost に CORS 対応した適当のアプリを立ち上げても OK です

アクセスに成功すればその結果を data.value に設定します

2022年10月14日金曜日

Quasarでcookiesを扱う方法

Quasarでcookiesを扱う方法

概要

q-carousel の使い方をサンプルコード付きで紹介します
今回は q-btn-toggle を組み合わせて使いQuasar で Cookies を扱う方法を紹介します

環境

  • macOS 11.7
  • node 18.9.0
  • quasar 2.8.2

サンプルコード

  • vim src/pages/IndexPage.vue
<template>
  <q-btn label="Save" @click="saveCookie()" />
  <q-btn label="Clear" @click="clearCookie()" />
  <div>{{ userName }}</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Cookies } from 'quasar'

const cookieName = 'my_cookie_name'
const userName = ref(Cookies.get(cookieName))

const saveCookie = () => {
  const hasIt = Cookies.has(cookieName)
  if (!hasIt) {
    Cookies.set(cookieName, 'hawksnowlog')
  }
  userName.value = Cookies.get(cookieName)
}

const clearCookie = () => {
  Cookies.remove(cookieName)
  userName.value = Cookies.get(cookieName)
}
</script>

ちょっと解説

書き込む際は Cookies.set を使います
読み込む際は Cookies.get を使います
存在するかのチェックは Cookies.has を使います

なおオプションで期限やドメイン情報などを設定することができます

参考サイト

2022年10月13日木曜日

Quasarでfontawesomeを使う方法

Quasarでfontawesomeを使う方法

概要

q-carousel の使い方をサンプルコード付きで紹介します
今回は q-btn-toggle を組み合わせて使います

環境

  • macOS 11.7
  • node 18.9.0
  • quasar 2.8.2

quasar.conf.js の編集

extras という配列の項目があるのでそこの「fontawesome-v6」をコメントインします

  • vim quasar.config.js
    extras: [
      // 'ionicons-v4',
      // 'mdi-v5',
      'fontawesome-v6',
      // 'eva-icons',
      // 'themify',
      // 'line-awesome',
      // 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!

      'roboto-font', // optional, you are not bound to it
      'material-icons', // optional, you are not bound to it
    ],

q-icon で使う

あとは以下のように参照するだけです

<q-icon name="fa-brands fa-gitlab" />

サイズを指定する場合は size プロパティを使えば OK です

<q-icon name="fa-brands fa-gitlab" size="10em"/>

もしくは直接 vue ファイルで import しても OK

<template>
  <q-icon :name="fabGitlab" size="10em"/>
</template>

<script setup lang="ts">
import { fabGitlab } from '@quasar/extras/fontawesome-v6';
</script>

最後に

vue 用に font-awesome-icon というタグが用意されているのでそれを使っても良さそうな気がします

参考サイト

2022年10月12日水曜日

QuasarでQTableのexpandを使う方法

QuasarでQTableのexpandを使う方法

概要

特定のカラムの情報だけexpandで表示したい場合などに使えます
expand 後に更にテーブルを使う方法も紹介します

環境

  • macOS 11.7
  • node 18.9.0
  • quasar 2.8.2

サンプルコード

  • vim src/pages/IndexPage.vue
<template>
  <div class="q-pa-md">
    <q-table
      title="Treats"
      :rows="rows"
      :columns="columns"
      row-key="name"
    >

      <template v-slot:header="props">
        <q-tr :props="props">
          <q-th auto-width />
          <q-th
            v-for="col in props.cols"
            :key="col.name"
            :props="props"
          >
            {{ col.label }}
          </q-th>
        </q-tr>
      </template>

      <template v-slot:body="props">
        <q-tr :props="props">
          <q-td auto-width>
            <q-btn size="sm" color="accent" round dense @click="props.expand = !props.expand" :icon="props.expand ? 'remove' : 'add'" />
          </q-td>
          <q-td
            v-for="col in props.cols"
            :key="col.name"
            :props="props"
          >
            {{ col.value }}
          </q-td>
        </q-tr>
        <q-tr v-show="props.expand">
          <q-td
            colspan="100%"
            v-for="favorite in props.row.favorites"
            :key="favorite.id"
          >
            <q-chip color="primary" text-color="white" icon="edit">
              {{ favorite.color }}
            </q-chip>
            <q-chip color="red" text-color="white" icon="sports">
              {{ favorite.sports }}
            </q-chip>
            <q-chip color="green" text-color="white" icon="restaurant">
              {{ favorite.food}}
            </q-chip>
          </q-td>
        </q-tr>
      </template>

    </q-table>
  </div>
</template>

<script setup lang="ts">
const columns = [
  { name: 'name', required: true, label: 'name', align: 'left', field: 'name', sortable: true },
  { name: 'age', label: 'age', field: 'age', sortable: true },
]

const rows = [
  {
    id: 1,
    name: 'hawk',
    age: 10,
    favorites: [{'color': 'blue', 'sports': 'soccer', 'food': 'bread'}],
  },
  {
    id: 2,
    name: 'snowlog',
    age: 20,
    favorites: [{'color': 'red', 'sports': 'basketball', 'food': 'rice'}],
  },
  {
    id:3,
    name: 'taro',
    age: 30,
    favorites: [{'color': 'green', 'sports': 'tennis', 'food': 'meat'}],
  },
]
</script>

expand 後の更にQTableを使う方法

<template>
  <div class="q-pa-md">
    <q-table
      title="Treats"
      :rows="rows"
      :columns="columns"
      row-key="name"
    >

      <template v-slot:header="props">
        <q-tr :props="props">
          <q-th auto-width />
          <q-th
            v-for="col in props.cols"
            :key="col.name"
            :props="props"
          >
            {{ col.label }}
          </q-th>
        </q-tr>
      </template>

      <template v-slot:body="props">
        <q-tr :props="props">
          <q-td auto-width>
            <q-btn size="sm" color="accent" round dense @click="props.expand = !props.expand" :icon="props.expand ? 'remove' : 'add'" />
          </q-td>
          <q-td
            v-for="col in props.cols"
            :key="col.name"
            :props="props"
          >
            {{ col.value }}
          </q-td>
        </q-tr>
        <q-tr v-show="props.expand">
          <q-td
            colspan="100%"
          >
            <q-table
              title="Favorites"
              :rows="props.row.favorites"
              :columns="nextedColumns"
              row-key="id"
              flat
            >
            </q-table>
          </q-td>
        </q-tr>
      </template>

    </q-table>
  </div>
</template>

<script setup lang="ts">
const columns = [
  { name: 'name', required: true, label: 'name', align: 'left', field: 'name', sortable: true },
  { name: 'age', label: 'age', field: 'age', sortable: true },
]

const nextedColums = [
  { name: 'color', label: 'color', field: 'color' },
  { name: 'sports', label: 'sports', field: 'sports' },
  { name: 'food', label: 'food', field: 'food' },
]

const rows = [
  {
    id: 1,
    name: 'hawk',
    age: 10,
    favorites: [{'color': 'blue', 'sports': 'soccer', 'food': 'bread'}],
  },
  {
    id: 2,
    name: 'snowlog',
    age: 20,
    favorites: [{'color': 'red', 'sports': 'basketball', 'food': 'rice'}],
  },
  {
    id:3,
    name: 'taro',
    age: 30,
    favorites: [{'color': 'green', 'sports': 'tennis', 'food': 'meat'}],
  },
]
</script>

ちょっと解説

ポイントは入れ子にするオブジェクト専用のカラム情報を作成する点です
あとは値を渡すときに props.row.favarites という感じでexpandした際にだけ表示するカラムをループで渡してあげればOKです

また flat 属性を指定してあげるとテーブルが入れ子になってもキレイに表示されるのでおすすめです