🤖

Static Web Appsでの認証・認可

14 min read

はじめに

前回からの続きで、Static Web Appsで、Active Directory(以下、AD)での認証・認可を利用し、ユーザIDおよびメニューの表示・非表示ができる画面を作成します。GitHubやTwitterにも対応しているようです。

ちなみに、ローカルで動作確認までできますが、Azure上では有料になります。個人で試したい方は、作成後にすぐに削除すれば、1日使っても40円程度なので、気軽に試せます。

前提条件

  • Static Web Appsの環境構築ができる ... 手順
  • Vite + Bootstrapが利用できる ... 手順

画面開発

ローカルでの開発環境構築

Static Web Appsの認証・認可機能は、ローカル環境でも動作確認ができるようにCLIが用意されています。viteで新規プロジェクトを作成し、以下のコマンドを実行してください。

npm install -g @azure/static-web-apps-cli
npm run build
swa start ./dist
→ 「--api-location ./api/dist」を追加すると、functionも一緒に起動できる。

以下のURLで、ログイン後にユーザ情報が取得できるか、ログアウトでユーザ情報が消えるかを確認してみてください。カスタムロールも変更可能です。

セッション情報は、cookiesのStaticWebAppsAuthCookieで保存されており、ブラウザをすべて閉じると消えます。また、入力したログイン情報は、ローカルストレージに保存されるようです。

実際の開発では、毎回buildするの大変なので、以下の設定ファイルを作成し、viteでサービスを起動し、proxy経由で、すべてlocalhost:4280から参照できます。「swa start」で起動。

swa-cli.config.json
{
  "configurations": {
    "app": {
      "context": "http://localhost:3000",
      "run": "npm run dev"
    }
  }
}

ただし、viteだけの事象ですが、proxy経由だとnode_module/.viteが参照できず、404が発生します。暫定対応として、初回だけlocalhost:4280で起動し、ブラウザのキャッシュにヒットさせると、swa startでも確認できます。

package.json
  "scripts": {
    "dev": "vite --port 4280",    // 1度表示したら、port指定を削除する。

他にも、vite.config.tsにproxyを設定する手も考えたのですが、logout後に4280に戻ってしまうので、微妙でした。proxy経由でnode_module/.viteが見れる方法があれば、コメントを頂きたいです。

データストア

Vue.jsでデータストアと言えば、Vuexですが、型推論ができなかったりと、色々と使いづらいところが多いため、今回はPiniaを使用します。Piniaの特徴を軽く記載しておきます。

  • Vuexの次のイテレーションの提案をテストするために作られた。Vuex5のRFCにも採用されている。
  • Typescriptを完全サポートしている。
  • mutationsがなく、actionから操作できる。Vuex5でも採用される。
  • 2021/10/26で2.0.0となり、安定性が向上。

ここでは、routerで画面遷移する際に、APIで取得したユーザ情報から認証・認可情報を取得し、storeに保存します。使い方はDemoが参考になります。

main.ts
import { createPinia } from "pinia";
...
// createPinia()を追加
createApp(App).use(router).use(createPinia()).component("font-awesome-icon", FontAwesomeIcon).mount("#app");
stores/store.ts
import { defineStore, acceptHMRUpdate } from "pinia";

// Static Web Appsのユーザ情報
declare type ClientPrincipal = {
  identityProvider: string;
  userId: string;
  userDetails: string;
  userRoles: string[];
};

declare type ClientPrincipalBody = {
  clientPrincipal: ClientPrincipal;
};

export const useUserStore = defineStore({
  id: "user",

  state: () => ({
    name: "",
    isAuth: false,
    isAdmin: false,
  }),

  actions: {
    // ログインしているかどうか
    async isLoggedIn() {
      // 確認用、どのタイミングでログインチェックしているか。
      console.log("isLoggedIn");
      // 認証情報を取得する。
      var body: ClientPrincipalBody = await fetch("/.auth/me").then((res) => res.json());
      if (body.clientPrincipal == null) {
        // 認証情報が取得できなかった場合は、ログアウトでユーザ情報を削除する。
        this.name = "";
        this.isAuth = false;
        this.isAdmin = false;
      } else {
        // ユーザ名、メールアドレスは@以降を削除
        this.name = body.clientPrincipal.userDetails;
        var domainIndex = this.name.indexOf("@");
        if (domainIndex > 0) {
          this.name = this.name.substring(0, domainIndex);
        }
        // 認証済みにする。
        this.isAuth = true;
        // Adminチェック
        if (body.clientPrincipal.userRoles.includes("admin")) {
          this.isAdmin = true;
        }
        return true;
      }
    },
  },
});

// storeでHMRを有効にする。Viteのみ正式サポート
if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot));
}

引き続き、設定したstore情報を利用し、vue-routerの認可設定を行っていきます。

vue-routerで認可設定

vue-routerでは、adminロールのユーザのみ、admin画面のメニューやページが表示できるようにし、認可設定を行います。更に、参照権限がないユーザに対しては、403を表示させるようにします。

  1. Home, Admin、403, 404ページを作成し、App.vueをrouter-viewのみにします。Home、404は前回分を参照してください。
views/Admin.vue
<template>
  <h1 class="mt-4">Admin</h1>
</template>
components/Forbidden.vue
<template>
  <div class="container">
    <div class="row justify-content-center">
      <div class="col-lg-6">
        <div class="text-center mt-4">
          <h1 class="display-1">403</h1>
          <p class="lead">Forbidden</p>
          <p>指定されたページは閲覧権限がありません。</p>
          <a href="/">
            <i class="fas fa-arrow-left me-1"></i>
            Back
          </a>
        </div>
      </div>
    </div>
  </div>
</template>
App.vue
<template>
  <router-view />
</template>
  1. 画面遷移時に、ログインチェックおよび認可チェックを行うようにします。
router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import { useUserStore } from "../stores/user";
import AppHome from "../views/AppHome.vue";
import Admin from "../views/Admin.vue";
import NotFound from "../components/NotFound.vue";
import Forbidden from "../components/Forbidden.vue";

const routers: Array<RouteRecordRaw> = [
  {
    path: "/",
    name: "home",
    component: AppHome,
  },
  {
    // Adminページを追加
    path: "/admin",
    name: "admin",
    component: Admin,
    // 認可情報を追加
    meta: { isAdmin: true },
  },
  {
    // 403ページ追加
    path: "/forbidden",
    name: "Forbidden",
    component: Forbidden,
  },
  {
    path: "/:pathMatch(.*)*",
    name: "notFound",
    component: NotFound,
  },
];

const router = createRouter({
  history: createWebHistory(),
  routes: routers,
});

router.beforeEach(async (to, from) => {
  const userStore = useUserStore();

  // ログイン、ログアウト後はルートに戻るため、直接ルート指定は必ずログインチェックを行う。
  // また、ログイン情報が取得できない場合も同様。
  if ((to.path == "/" && from.path == "/") || !userStore.isAuth) {
    if (!(await userStore.isLoggedIn())) {
      // ダイレクトアクセス
      location.pathname = "/.auth/login/aad";
    }
  }
  // Admin権限がないユーザで、Adminページを開いた場合、403エラーとする。
  if (to.matched.some((record) => record.meta.isAdmin) && !userStore.isAdmin) {
    return "/forbidden";
  }
});
export default router;

ここまでの動作確認として、ログイン有無によるhome画面の違い、adminロール有無によるadmin画面の違いについて、色々と確認してみてください。

ナビゲーションバー修正

次に、ナビゲーションバーで、メニュー認可、ユーザ情報表示、ログオフメニューを追加します。

components/AppNavi.vue
<script lang="ts">
export interface MenuItem {
  type: "heading" | "menu";
  title: string;
  icon?: string;
  url?: string;
  // Admin権限フラグを追加
  isAdmin?: boolean;
}
</script>

<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useUserStore } from "../stores/user";

// storeからユーザ情報を取得
const userStore = useUserStore();

defineProps<{ title: string; menuItems: MenuItem[] }>();

const router = useRouter();

const isToggle = ref(false);

const goToUrl = (url?: string) => {
  if (url != undefined) {
    router.push(url);
  }
};
</script>

<template>
  <body class="sb-nav-fixed" :class="isToggle ? 'sb-sidenav-toggled' : ''">
    <nav class="sb-topnav navbar navbar-expand navbar-dark bg-dark">
      <a class="navbar-brand ps-3" @click="goToUrl('/')">{{ title }}</a>
      <button id="sidebarToggle" class="btn btn-link btn-sm order-1 order-lg-0 me-4 me-lg-0" @click="isToggle = !isToggle">
        <i class="fas fa-bars"></i>
      </button>
      <!-- 右上のドロップダウンメニューを追加 -->
      <ul class="navbar-nav ms-auto me-0 me-md-3">
        <li class="nav-item dropdown">
          <a class="nav-link dropdown-toggle" id="navbarDropdown" href="#" role="button" data-bs-toggle="dropdown">
            <i class="fas fa-user fa-fw"></i>
            {{ userStore.name }}&nbsp;
          </a>
          <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
            <li><a class="dropdown-item" href="/.auth/logout">Logout</a></li>
          </ul>
        </li>
      </ul>
    </nav>
    <div id="layoutSidenav">
      <div id="layoutSidenav_nav">
        <nav id="sidenavAccordion" class="sb-sidenav accordion sb-sidenav-dark">
          <div class="sb-sidenav-menu">
            <div v-for="(item, index) in menuItems" :key="index" class="nav">
              <div v-if="item.type == 'heading'" class="sb-sidenav-menu-heading">{{ item.title }}</div>
              <!-- Admin権限の画面は、Admin権限が付与されたユーザのみ表示されるように修正 -->
              <a
                v-if="item.type == 'menu' && (item.isAdmin == undefined || (item.isAdmin && userStore.isAdmin))"
                class="nav-link"
                @click="goToUrl(item.url)"
              >
                <div class="sb-nav-link-icon"><i class="fas fa-tachometer-alt" :class="item.icon"></i></div>
                {{ item.title }}
              </a>
            </div>
          </div>
        </nav>
      </div>
      <div id="layoutSidenav_content">
        <main>
          <div class="container-fluid px-4">
            <router-view />
          </div>
        </main>
      </div>
    </div>
  </body>
</template>
App.vue
<script setup lang="ts">
import AppNavi, { MenuItem } from "./components/AppNavi.vue";

// メニューを設定する。
const menuItems: MenuItem[] = [
  {
    type: "heading",
    title: "Main",
  },
  {
    type: "menu",
    title: "Admin",
    icon: "fa-lock",
    url: "/admin",
    isAdmin: true,
  },
];
</script>

<template>
  <AppNavi title="Auth Test" :menu-items="menuItems"></AppNavi>
</template>

動作確認として、adminロールによって、メニューが表示/非表示になるか、ログオフをすると、ログイン画面に遷移するか確認してみてください。

ビルド・デプロイ

ビルド・デプロイ手順は、静的WebアプリをStandardで構築してください。詳細の手順は、前回と同じなので、省略します。ビルドする際、Pipelineのapiビルドはコメントアウトしてください。
この段階では、ADのグループとadminとの紐づけができていないので、ログインをしても、メニューに何も表示されない状態です。

Azureでの設定

基本的には、Static Web Appsのチュートリアル「プログラムを使用してユーザロールを設定する」を参考に、設定を行っていきます。

ADでの認可設定

ADでユーザおよびadminグループを作成し、AD認証を行うアプリの登録を行う。

  1. AzureからActive Directoryを開く。
  2. グループで、新しいグループを選択し、テスト用のadminグループを作成する。
    ・グループ名 ... admin
    ・その他 ... デフォルトのまま
  3. 作成したグループのオブジェクトIDを保管する。
  4. ユーザで、新しいユーザを選択し、テスト用にtest1ユーザを作成する。
    ・ユーザ名 ... test1
    ・グループ ... admin
    ・その他 ... デフォルトのまま

onwerユーザはグループを追加しても、Microsoft Graphからグループ情報が取得できないようです。

  1. アプリの登録を選択し、以下の情報を登録し、概要に表示される「アプリケーション (クライアント) ID」「ディレクトリ (テナント) ID」を保管する。
    ・名前 ... menu-auth(任意)
    ・リダイレクトURI ... <YOUR_SITE_URL>/.auth/login/aad/callback
  2. 5で登録したアプリに対して、認証を選択し、「暗黙的な許可およびハイブリッド フロー」にある「IDトークン」を有効し、保存する。
  3. 証明書とシークレットを選択し、「新しいクライアント シークレット」で作成した値(シークレットIDではない)を保管する。作成時のみ参照可能。
    ・名前 ... menu-auth(任意)
  4. 静的 Web アプリで、作成したアプリの構成を選択し、アプリケーション設定で、以下の変数を追加する。
    ・AAD_CLIENT_ID ... 5で取得したアプリケーション (クライアント) ID
    ・AAD_CLIENT_SECRET ... 7で取得したシークレットID

ADとのカスタムロール割り当て

Azure Functionで、ADのグループとカスタムロールとの割り当てを行います。

  1. VSCodeで、[F1]を押して、「Azure Static Web Apps: Create HTTP Function」を選択し、Functionを作成する。
    ・Select a language ... TypeScript
    ・Function name ... GetRoles
  2. チュートリアルのサンプルソースを参考に、以下のソースに差し替える。Typescript版ではfetchを使えるように、cross-fetchを利用する。
> cd api
> npm i --save cross-fetch
index.ts
import { AzureFunction, Context, HttpRequest } from "@azure/functions";
import fetch from "cross-fetch";

const roleGroupMappings = {
  admin: "3で取得したオブジェクトID",
};

const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise<void> {
  const user = req.body || {};
  const roles = [];

  for (const [role, groupId] of Object.entries(roleGroupMappings)) {
    if (await isUserInGroup(groupId, user.accessToken)) {
      roles.push(role);
    }
  }

  context.res.json({
    roles,
  });
};

async function isUserInGroup(groupId, bearerToken) {
  const url = new URL("https://graph.microsoft.com/v1.0/me/memberOf");
  url.searchParams.append("$filter", `id eq '${groupId}'`);
  const response = await fetch(url.href, {
    method: "GET",
    headers: {
      Authorization: `Bearer ${bearerToken}`,
    },
  });

  if (response.status !== 200) {
    return false;
  }

  const graphResponse = await response.json();
  const matchingGroups = graphResponse.value.filter((group) => group.id === groupId);
  return matchingGroups.length > 0;
}
export default httpTrigger;
  1. Static Web Appsの認証設定を追加する。
staticwebapp.config.json
{
  "auth": {
    "rolesSource": "/api/GetRoles",
    "identityProviders": {
      "azureActiveDirectory": {
        "userDetailsClaim": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
        "registration": {
          "openIdIssuer": "https://login.microsoftonline.com/<5で取得したディレクトリ (テナント) ID>",
          "clientIdSettingName": "AAD_CLIENT_ID",
          "clientSecretSettingName": "AAD_CLIENT_SECRET"
        },
        "login": {
          "loginParameters": ["resource=https://graph.microsoft.com"]
        }
      }
    }
  }
}

再ビルド・デプロイ

APIもビルドできるようにPipelineを修正し、再ビルド・デプロイを行ってください。
これでやっと、Static Web Apps上で、ADでの認証・認可ができるようなったと思います。少しでも手順を間違えると、エラーで動かなくなるので注意してください。

終わりに

azure/static-web-apps-cliは、ローカルで認証・認可確認ができるので、非常に便利でした。viteではうまく動かず、暫定的に利用しましたが、ReactやVueだと普通に使えると思います。他にも、IP制限、フォルダアクセス制限、カスタムドメインなどができますが、公式ページの説明で十分だと思います。

今後も、引き続き、Azureでのサーバレス関連の記事を掲載していく予定なので、よろしくお願いいたします。

Discussion

ログインするとコメントできます