Static Web Appsでの認証・認可
はじめに
前回からの続きで、Static Web Appsで、Active Directory(以下、AD)での認証・認可を利用し、ユーザIDおよびメニューの表示・非表示ができる画面を作成します。GitHubやTwitterにも対応しているようです。
ちなみに、ローカルで動作確認までできますが、Azure上では有料になります。個人で試したい方は、作成後にすぐに削除すれば、1日使っても40円程度なので、気軽に試せます。
前提条件
画面開発
ローカルでの開発環境構築
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で、ログイン後にユーザ情報が取得できるか、ログアウトでユーザ情報が消えるかを確認してみてください。カスタムロールも変更可能です。
- http://localhost:4280/.auth/me ... ユーザ情報
- http://localhost:4280/.auth/login/aad ... ログイン画面
- http://localhost:4280/.auth/logout ... ログアウト
セッション情報は、cookiesのStaticWebAppsAuthCookieで保存されており、ブラウザをすべて閉じると消えます。また、入力したログイン情報は、ローカルストレージに保存されるようです。
実際の開発では、毎回buildするの大変なので、以下の設定ファイルを作成し、viteでサービスを起動し、proxy経由で、すべてlocalhost:4280から参照できます。「swa start」で起動。
{
"configurations": {
"app": {
"context": "http://localhost:3000",
"run": "npm run dev"
}
}
}
以下の事象は、swa v0.8.2で解消されるようです!morishinさんに感謝。
ただし、viteだけの事象ですが、proxy経由だとnode_module/.viteが参照できず、404が発生します。暫定対応として、初回だけlocalhost:4280で起動し、ブラウザのキャッシュにヒットさせると、swa startでも確認できます。
"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が参考になります。
import { createPinia } from "pinia";
...
// createPinia()を追加
createApp(App).use(router).use(createPinia()).component("font-awesome-icon", FontAwesomeIcon).mount("#app");
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を表示させるようにします。
- Home, Admin、403, 404ページを作成し、App.vueをrouter-viewのみにします。Home、404は前回分を参照してください。
<template>
<h1 class="mt-4">Admin</h1>
</template>
<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>
<template>
<router-view />
</template>
- 画面遷移時に、ログインチェックおよび認可チェックを行うようにします。
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画面の違いについて、色々と確認してみてください。
ナビゲーションバー修正
次に、ナビゲーションバーで、メニュー認可、ユーザ情報表示、ログオフメニューを追加します。
<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 }}
</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>
<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認証を行うアプリの登録を行う。
- AzureからActive Directoryを開く。
- グループで、新しいグループを選択し、テスト用のadminグループを作成する。
・グループ名 ... admin
・その他 ... デフォルトのまま - 作成したグループのオブジェクトIDを保管する。
- ユーザで、新しいユーザを選択し、テスト用にtest1ユーザを作成する。
・ユーザ名 ... test1
・グループ ... admin
・その他 ... デフォルトのまま
- アプリの登録を選択し、以下の情報を登録し、概要に表示される「アプリケーション (クライアント) ID」「ディレクトリ (テナント) ID」を保管する。
・名前 ... menu-auth(任意)
・リダイレクトURI ... <YOUR_SITE_URL>/.auth/login/aad/callback - 5で登録したアプリに対して、認証を選択し、「暗黙的な許可およびハイブリッド フロー」にある「IDトークン」を有効し、保存する。
- 証明書とシークレットを選択し、「新しいクライアント シークレット」で作成した値(シークレットIDではない)を保管する。作成時のみ参照可能。
・名前 ... menu-auth(任意) - 静的 Web アプリで、作成したアプリの構成を選択し、アプリケーション設定で、以下の変数を追加する。
・AAD_CLIENT_ID ... 5で取得したアプリケーション (クライアント) ID
・AAD_CLIENT_SECRET ... 7で取得したシークレットID
ADとのカスタムロール割り当て
Azure Functionで、ADのグループとカスタムロールとの割り当てを行います。
- VSCodeで、[F1]を押して、「Azure Static Web Apps: Create HTTP Function」を選択し、Functionを作成する。
・Select a language ... TypeScript
・Function name ... GetRoles - チュートリアルのサンプルソースを参考に、以下のソースに差し替える。Typescript版ではfetchを使えるように、cross-fetchを利用する。
> cd api
> npm i --save cross-fetch
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;
- Static Web Appsの認証設定を追加する。
{
"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は、ローカルで認証・認可確認ができるので、非常に便利でした。他にも、IP制限、フォルダアクセス制限、カスタムドメインなどができますが、公式ページの説明で十分だと思います。
今後も、引き続き、Azureでのサーバレス関連の記事を掲載していく予定なので、よろしくお願いいたします。
Discussion
swa v0.8.1 だと再現しましたが、v0.8.2 にアップグレードすると私の手元では直りました!
情報提供、ありがとうございます!記事を更新してみました。