小ネタです。
Blazor WebAssembly で Cookie 認証のログインを実装すると、Blazorのロードのクルクルが二回実行されちゃいますよね。
- ログイン画面表示前にBlazorの初期化
- ログイン成功で Cookie が付与されてもう一回アプリ起動で初期化
その対応としてこれをやってみます。ポイントはこの2つだけ。
autostart="false"await Blazor.start();
デフォルトでは autostart は true なので index.html を読み込んだらすぐに Blazorの初期化が実行されます。これを false にすることでタイミングをコントロールできます。
1) 認証済みなら起動、未認証なら login.html へ
index.html 側で Blazor の自動起動を止めておき、まず認証済みかを確認します。このサンプルでは current_userを叩いてます。
- 認証済み →
Blazor.start()して通常起動 - 未認証 →
login.htmlへリダイレクト(returnUrl 付き)
あとちょっとしたポイントで、何もしないとログイン画面にいくときもローディングのクルクルが一瞬見えてしまいます。それを防ぐために pre-auth を作って認証前の状態ではクルクルを隠しています。
それと未認証時にURLを指定された場合にそれをURLパラメータとして保存してログイン画面に引数で渡すようにしています。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>LowCodeApp</title>
<base href="/" />
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="css/app.css" rel="stylesheet" />
<link rel="icon" type="image/png" href="favicon.png" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link href="LowCodeApp.Client.styles.css" rel="stylesheet" />
<link href="_content/Sotsera.Blazor.Toaster/toastr.min.css" rel="stylesheet" />
<!-- ★ポイント 認証前にはクルクルを隠す-->
<style>
#app.pre-auth .loading-progress,
#app.pre-auth .loading-progress-text {
visibility: hidden !important;
}
</style>
<script>
window.jsFunctions = {
getCookie: function () {
return document.cookie || "";
}
};
</script>
</head>
<body>
<div id="app" class="pre-auth">
<svg class="loading-progress">
<circle r="40%" cx="50%" cy="50%" />
<circle r="40%" cx="50%" cy="50%" />
</svg>
<div class="loading-progress-text"></div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<!-- ★ポイント Blazorの初期化は止める-->
<script src="_framework/blazor.webassembly.js" autostart="false"></script>
<script>
async function isAuthenticated() {
try {
const res = await fetch("api/account/current_user", { credentials: "include", cache: "no-store" });
if (!res.ok) return false;
const data = await res.json();
const id = (data?.value ?? data?.Value ?? "").trim();
return id.length > 0;
} catch { return false; }
}
function sanitizeReturnUrl(u) {
try {
if (!u) return "/";
if (!u.startsWith("/")) return "/";
if (u.startsWith("//")) return "/";
const path = u.split("?")[0].toLowerCase();
if (path === "/login" || path === "/logout" || path.startsWith("/auth/")) {
return "/";
}
return u;
} catch { return "/"; }
}
(async function boot() {
if (await isAuthenticated()) {
const app = document.getElementById('app');
app?.classList.remove('pre-auth');
//★認証通ったらBlazorを開始する
await Blazor.start();
window.__blazorStarted = true;
return;
}
//★ログイン画面に遷移、URLをパラメータで渡す
const safe = sanitizeReturnUrl(location.pathname + location.search + location.hash);
if (safe === "/" || safe === "") {
location.replace("/login.html");
} else {
location.replace(`/login.html?returnUrl=${encodeURIComponent(safe)}`);
}
})();
</script>
</body>
</html>
これで「未認証でアクセスしたときの初回起動」は Blazor じゃなくて静的な login.html を表示するのでサクッと表示されます
2) ログイン画面は素のHTML
login.html は Blazor を使わず、プレーンな HTML + fetch でログイン API を叩きます。
成功したら returnUrl に戻すだけ。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="/" />
<title>Login</title>
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="css/app.css" rel="stylesheet" />
<link rel="icon" type="image/png" href="favicon.png" />
<style>
.empty-center {
padding: 16px;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<div class="empty-center">
<div>
<h1>Login</h1>
<form id="LoginForm" novalidate>
<div class="mb-2">
User ID
<input id="Id" name="Id" type="text" style="width:300px" class="form-control" autocomplete="username" required />
</div>
<div class="mb-2">
Password
<input id="Password" name="Password" type="password" style="width:300px" class="form-control" autocomplete="current-password" required />
</div>
<div class="mb-3">
<input id="IsPersistent" name="IsPersistent" type="checkbox" />
<label for="IsPersistent" class="ms-1">Remember me?</label>
</div>
<button id="LoginButton" type="submit" class="btn btn-primary">Login</button>
<div id="ErrorMessage" class="text-danger mt-3" style="display:none;">Login failed.</div>
</form>
</div>
</div>
<script>
const params = new URLSearchParams(location.search);
const redirect = sanitizeReturnUrl(params.get("returnUrl"));
const token = getCookie("X-ANTIFORGERY-TOKEN");
(async () => {
if (window.__blazorStarted) location.reload();
})();
document.getElementById("LoginForm").addEventListener("submit", async (e) => {
e.preventDefault();
const id = document.getElementById("Id").value;
const password = document.getElementById("Password").value;
const isPersistent = document.getElementById("IsPersistent").checked;
if (!id || !password) {
showError(); return;
}
const btn = document.getElementById("LoginButton");
const err = document.getElementById("ErrorMessage");
btn.disabled = true;
err.style.display = "none";
try {
const res = await fetch("/api/account/login", {
method: "POST",
headers: { "Content-Type": "application/json", "X-ANTIFORGERY-TOKEN":token },
body: JSON.stringify({ Id: id, Password: password, IsPersistent: isPersistent }),
credentials: "include",
cache: "no-store"
});
if (res.ok) {
const me = await fetch("/api/account/current_user", { credentials: "include", cache: "no-store" });
if (me.ok) {
const j = await me.json();
const uid = (j?.value ?? j?.Value ?? "").trim();
if (uid) {
if (redirect === "/" || redirect === "") location.href = "/";
else location.href = redirect;
return;
}
}
showError();
} else {
showError();
}
} catch {
showError();
} finally {
btn.disabled = false;
}
});
function getCookie(name) {
return document.cookie
.split("; ")
.find(x => x.startsWith(name + "="))
?.split("=")[1] ?? "";
}
function showError() {
const el = document.getElementById("ErrorMessage");
el.textContent = "Login failed";
el.style.display = "block";
}
function sanitizeReturnUrl(u) {
try {
if (!u) return "/";
if (!u.startsWith("/")) return "/";
if (u.startsWith("//")) return "/";
const path = u.split("?")[0].toLowerCase();
if (path === "/login" || path === "/logout" || path.startsWith("/auth/")) {
return "/";
}
return u;
} catch { return "/"; }
}
</script>
</body>
</html>
ついでに X-ANTIFORGERY-TOKEN はどこでつけてんねんってことですが、html を Get するタイミングでつけています。
app.Use(async (ctx, next) =>
{
if (HttpMethods.IsGet(ctx.Request.Method))
{
if (ctx.Request.Headers.Accept.Any(a => a?.Contains("text/html") == true))
{
var anti = ctx.RequestServices.GetRequiredService<IAntiforgery>();
var tokens = anti.GetAndStoreTokens(ctx);
ctx.Response.Cookies.Append(
"X-ANTIFORGERY-TOKEN",
tokens.RequestToken??string.Empty,
new CookieOptions
{
HttpOnly = false,
Secure = true,
SameSite = SameSiteMode.Lax
});
}
}
await next();
});
コードは Codeer.LowCode.Blazor テンプレートのVisualStudio拡張から生成できます
ここに書いたコードは Codeer.LowCode.Blazor テンプレートにも入っています。
VisualStudio拡張は無料なんで是非インストールしてみてください。Codeer.LowCode.Blazor.Cookieっていうので作成したら出てきます。Codeer.LowCode.Blazorでは認証はライブラリ外にだしていて、このテンプレートではASP.NET Core Identity の仕組みを使っています。ポスグレのDBとつなぐコードになってますけど、お手元のDBに合わせて変更お願いします。