4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WASMのBlazorでログイン画面をサクッと出す

Posted at

小ネタです。

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に合わせて変更お願いします。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?