C#でSPAが開発できるBlazorにAuth0で認証機能を付けてみる
CX事業本部@大阪の岩田です。
Auth0のブログで紹介されている、BlazorにAuth0の認証機能を組み込むチュートリアルを試してみたので内容をご紹介します。
What is Blazor? A Tutorial on Building Web Apps with Authentication
環境
今回利用した環境です。
- OS:Mac OS X 10.14.6
- .NET Core: 3.0.101
そもそもBlazorとは?
このチュートリアルではBlazorというフレームワークを利用します。Microsoftのドキュメントによると、Blazorは以下のように紹介されています。
Blazor は、.NET を使って対話型のクライアント側 Web UI を構築するためのフレームワークです。
そうです。なんとC#でSPAの開発ができる!!という素晴らしいフレームワークなんです。Blazorはコンポーネント指向のフレームワークで、コンポーネントはRazor構文で記述します。Blazorという名前はブラウザ(Browser)と Razorが由来だそうです。
Blazorには「Blazor WebAssembly」と「Blazor Server」という2つのモデルが存在します。「Blazor Server」は.NET Core 3.0で正式にサポートされていますが、「Blazor WebAssembly」は.NET Core 3.1 preview限定で利用可能な状態で、GAは2020年5月に予定されています。
Blazor WebAssembly
こちらのモデルはその名の通りWASM(WebAssembly)を利用するモデルです。依存ライブラリや.NETランタイム、アプリケーションのコードが全てWebAssemblyにコンパイルされ、コンパイル結果をDLしたブラウザ上で全てのコードが実行されます。アプリケーションをホストするためにASP.NET Core Webサーバーが不要というメリットがありますが、ブラウザがWebAssemblyをサポートしている必要があり、アプリケーションの初回のDLに時間がかかるというデメリットがあります。
Blazor Server
こちらのモデルはUIの更新とイベント処理をサーバーサイドで行います。WebSocketの接続を介してサーバー<->ブラウザ間でバイナリのメッセージを交換することで、サーバーサイドでの処理結果がブラウザに伝わり、ブラウザ側で実際のDOMの更新が行われます。WebAssemblyモデルと比べるとブラウザーがDLするファイルサイズが小さくなったり、WebAssembly非対応のブラウザ上でも利用できたり、といったメリットがあります。一方でブラウザ⇔サーバー間の通信による遅延の増加やASP.NET Core Webサーバーが必要といったデメリットがあります。
やってみる
では、早速実際にSPAを作っていきましょう。このチュートリアルではBlazor Serverモデルで開発を進めていきます。
まずは.NETのバージョンが3.0.100以上になっていることを確認します。
$ dotnet --version 3.0.101
手元の環境のバージョンが3.0.101だったので、このまま進めていきます。
まずblazorserver
というテンプレートを元にQuizManager
というプロジェクトを作成します。
$ dotnet new blazorserver -o QuizManager
作成したプロジェクトのData
というディレクトリ以下にQuizItem.cs
というファイルを追加し、以下のコードを記述します。
using System; using System.Collections.Generic; namespace QuizManager.Data { public class QuizItem { public string Question { get; set; } public List<string> Choices { get; set; } public int AnswerIndex { get; set; } public int Score { get; set; } public QuizItem() { Choices = new List<string>(); } } }
同じくData
ディレクトリ配下にQuizService.cs
というファイルを追加し、以下の記述を追加します。このクラスがクイズのリストを返却してくれます。実際の開発ではDB等からクイズの一覧を取得すると思いますが、簡単なサンプルなのでクイズのリストはオンコードで記載しています。
using System; using System.Collections.Generic; using System.Threading.Tasks; namespace QuizManager.Data { public class QuizService { private static readonly List<QuizItem> Quiz; static QuizService() { Quiz = new List<QuizItem> { new QuizItem { Question = "Which of the following is the name of a Leonardo da Vinci's masterpiece?", Choices = new List<string> {"Sunflowers", "Mona Lisa", "The Kiss"}, AnswerIndex = 1, Score = 3 }, new QuizItem { Question = "Which of the following novels was written by Miguel de Cervantes?", Choices = new List<string> {"The Ingenious Gentleman Don Quixote of La Mancia", "The Life of Gargantua and of Pantagruel", "One Hundred Years of Solitude"}, AnswerIndex = 0, Score = 5 } }; } public Task<List<QuizItem>> GetQuizAsync() { return Task.FromResult(Quiz); } } }
続いてStartup.cs
を開き、ConfigureServices
メソッド内のservices.AddSingleton<WeatherForecastService>();
をservices.AddSingleton<QuizService>();
に変更します。
services.AddRazorPages(); services.AddServerSideBlazor(); services.AddSingleton<QuizService>();
Razorコンポーネントの追加
ここからRazorコンポーネントを追加していきます。まず Pages
ディレクトリのCounter.razor
と FetchData.razor
を削除し、代わりにQuizViewer.razor
というファイルを追加します。追加したQuizViewer.razorに以下通り記述します。
@page "/quizViewer" @using QuizManager.Data @inject QuizService QuizRepository <h1>Take your quiz!</h1> <p>Your current score is @currentScore</p> @if (quiz == null) { <p><em>Loading...</em></p> } else { int quizIndex = 0; @foreach (var quizItem in quiz) { <section> <h3>@quizItem.Question</h3> <div class="form-check"> @{ int choiceIndex = 0; quizScores.Add(0); } @foreach (var choice in quizItem.Choices) { int currentQuizIndex = quizIndex; <input class="form-check-input" type="radio" name="@quizIndex" value="@choiceIndex" @onchange="@((eventArgs) => UpdateScore(Convert.ToInt32(eventArgs.Value), currentQuizIndex))"/>@choice<br> choiceIndex++; } </div> </section> quizIndex++; } } @code { List<QuizItem> quiz; List<int> quizScores = new List<int>(); int currentScore = 0; protected override async Task OnInitializedAsync() { quiz = await QuizRepository.GetQuizAsync(); } void UpdateScore(int chosenAnswerIndex, int quizIndex) { var quizItem = quiz[quizIndex]; if (chosenAnswerIndex == quizItem.AnswerIndex) { quizScores[quizIndex] = quizItem.Score; } else { quizScores[quizIndex] = 0; } currentScore = quizScores.Sum(); } }
HTMLのタグとC#のコードが共存している...!!
続いてShared
ディレクトリ配下のNavMenu.razor
というファイルを以下のように修正します。
<div class="top-row pl-4 navbar navbar-dark"> <a class="navbar-brand" href="">QuizManager</a> <button class="navbar-toggler" @onclick="ToggleNavMenu"> <span class="navbar-toggler-icon"></span> </button> </div> <div class="@NavMenuCssClass" @onclick="ToggleNavMenu"> <ul class="nav flex-column"> <li class="nav-item px-3"> <NavLink class="nav-link" href="" Match="NavLinkMatch.All"> <span class="oi oi-home" aria-hidden="true"></span> Home </NavLink> </li> <li class="nav-item px-3"> <NavLink class="nav-link" href="quizViewer"> <span class="oi oi-list-rich" aria-hidden="true"></span> Quiz </NavLink> </li> </ul> </div> @code { bool collapseNavMenu = true; string NavMenuCssClass => collapseNavMenu ? "collapse" : null; void ToggleNavMenu() { collapseNavMenu = !collapseNavMenu; } }
最後にプロジェクト直下のApp.razor
を以下のように修正します。
<Router AppAssembly="@typeof(Program).Assembly"> <Found Context="routeData"> <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" /> </Found> <NotFound> <LayoutView Layout="@typeof(MainLayout)"> <p>Sorry, there's nothing at this address.</p> </LayoutView> </NotFound> </Router>
ここまでできたらdotnet run
でアプリを起動し動作確認してみましょう。
$ dotnet run info: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[0] User profile is available. Using '/Users/......./.aspnet/DataProtection-Keys' as key repository; keys will not be encrypted at rest. info: Microsoft.Hosting.Lifetime[0] Now listening on: https://localhost:5001 info: Microsoft.Hosting.Lifetime[0] Now listening on: http://localhost:5000 info: Microsoft.Hosting.Lifetime[0] Application started. Press Ctrl+C to shut down. info: Microsoft.Hosting.Lifetime[0] Hosting environment: Development info: Microsoft.Hosting.Lifetime[0] Content root path: /Users/......./QuizManager info: Microsoft.AspNetCore.Hosting.Diagnostics[1] Request starting HTTP/1.1 POST https://localhost:5001/_blazor/negotiate text/plain;charset=UTF-8 0 info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0] Executing endpoint '/_blazor/negotiate' info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1] Executed endpoint '/_blazor/negotiate' info: Microsoft.AspNetCore.Hosting.Diagnostics[2] Request finished in 49.3008ms 200 application/json info: Microsoft.AspNetCore.Hosting.Diagnostics[1] Request starting HTTP/1.1 GET https://localhost:5001/_blazor?id=XIAMKYqLjJNT-EXxBzQKAw info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0] Executing endpoint '/_blazor'
https://localhost:5001/
にアクセスすると、簡易なクイズページが表示されます。
開発者ツールのNWタブを確認するとWeb Socketでバイナリのメッセージを交換していることが分かります。
Auth0の認証機能を組み込んで見る
サンプルアプリの準備ができたので、ここからAuth0と連携した認証機能を組み込んでいきます。
まずAuth0のダッシュボードからApplications
→Create application
と進み、Regular Web Applications
を選択してアプリケーションを作成します。アプリケーションが作成できたら、Settings
からAllowed Callback URLs
にhttps://localhost:5001/callback
を、Allowed Logout URLs
にhttps://localhost:5001/
を設定して保存します。
続いてappsettings.json
にAuth0関連の設定を追加します。
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } }, "AllowedHosts": "*", "Auth0": { "Domain": "<Auth0のドメイン>", "ClientId": "<作成したアプリケーションのクライアントID>", "ClientSecret": "<作成したアプリケーションのシークレット>" } }
続いてパッケージを追加します。依存関係の問題で最新版がうまく入らなかったので、明示的に3.0.0を指定しています。
$ dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect -v 3.0.0
続いてStartup.cs
に以下のusingディレクティブを追加します。
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authentication.Cookies;
同じくStartup.cs
のConfigureServices()
メソッドを修正します。
services.AddRazorPages(); services.AddServerSideBlazor(); services.Configure<CookiePolicyOptions>(options => { // This lambda determines whether user consent for non-essential cookies is needed for a given request. options.CheckConsentNeeded = context => true; options.MinimumSameSitePolicy = SameSiteMode.None; }); // Add authentication services services.AddAuthentication(options => { options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; }) .AddCookie() .AddOpenIdConnect("Auth0", options => { // Set the authority to your Auth0 domain options.Authority = $"https://{Configuration["Auth0:Domain"]}"; // Configure the Auth0 Client ID and Client Secret options.ClientId = Configuration["Auth0:ClientId"]; options.ClientSecret = Configuration["Auth0:ClientSecret"]; // Set response type to code options.ResponseType = "code"; // Configure the scope options.Scope.Clear(); options.Scope.Add("openid"); // Set the callback path, so Auth0 will call back to http://localhost:3000/callback // Also ensure that you have added the URL as an Allowed Callback URL in your Auth0 dashboard options.CallbackPath = new PathString("/callback"); // Configure the Claims Issuer to be Auth0 options.ClaimsIssuer = "Auth0"; options.Events = new OpenIdConnectEvents { // handle the logout redirection OnRedirectToIdentityProviderForSignOut = (context) => { var logoutUri = $"https://{Configuration["Auth0:Domain"]}/v2/logout?client_id={Configuration["Auth0:ClientId"]}"; var postLogoutUri = context.Properties.RedirectUri; if (!string.IsNullOrEmpty(postLogoutUri)) { if (postLogoutUri.StartsWith("/")) { // transform to absolute var request = context.Request; postLogoutUri = request.Scheme + "://" + request.Host + request.PathBase + postLogoutUri; } logoutUri += $"&returnTo={ Uri.EscapeDataString(postLogoutUri)}"; } context.Response.Redirect(logoutUri); context.HandleResponse(); return Task.CompletedTask; } }; }); services.AddHttpContextAccessor(); services.AddSingleton<QuizService>();
続いてStartup.cs
のConfigure()
メソッドを修正します。
if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { app.UseExceptionHandler("/Error"); // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseCookiePolicy(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapBlazorHub(); endpoints.MapFallbackToPage("/_Host"); });
Razorコンポーネントの修正
ここからはコンポーネントを修正してきます。
まずPages
ディレクトリのIndex.razor
に@attribute [Authorize]
を追加します。
@page "/" @attribute [Authorize] <h1>Hello, world!</h1> Welcome to your new app.
同様にQuizViewer.razor
にも@attribute [Authorize]
を追加します。
@page "/quizViewer" @attribute [Authorize] @using QuizManager.Data @inject QuizService QuizRepository
続いてログイン用のエンドポイントを作成します。
$ dotnet new page --name Login --namespace QuizManager.Pages --output Pages
Pages
ディレクトリ配下にLogin.cshtml.cs
というファイルが出力されるので以下のように修正します。
using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc.RazorPages; namespace QuizManager.Pages { public class LoginModel : PageModel { public async Task OnGet(string redirectUri) { await HttpContext.ChallengeAsync("Auth0", new AuthenticationProperties { RedirectUri = redirectUri }); } } }
今度はログアウトのエンドポイントです。
$ dotnet new page --name Logout --namespace QuizManager.Pages --output Pages
同様にPages
ディレクトリ配下にLogout.cshtml.cs
というファイルが出力されるので以下のように修正します。
using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Mvc.RazorPages; namespace QuizManager.Pages { public class LogoutModel : PageModel { public async Task OnGet() { await HttpContext.SignOutAsync("Auth0", new AuthenticationProperties { RedirectUri = "/" }); await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); } } }
続いてApp.razor
を以下のように修正します。
<Router AppAssembly="@typeof(Program).Assembly"> <Found Context="routeData"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"> <Authorizing> <p>Determining session state, please wait...</p> </Authorizing> <NotAuthorized> <h1>Sorry</h1> <p>You're not authorized to reach this page. You need to log in.</p> </NotAuthorized> </AuthorizeRouteView> </Found> <NotFound> <p>Sorry, there's nothing at this address.</p> </NotFound> </Router>
Shared
ディレクトリにAccessControl.razor
というファイルを追加します。
<AuthorizeView> <Authorized> <a href="logout">Log out</a> </Authorized> <NotAuthorized> <a href="login?redirectUri=/">Log in</a> </NotAuthorized> </AuthorizeView>
最後にShared
ディレクトリのMainLayout.razor
を以下の通り修正します。
@inherits LayoutComponentBase <div class="sidebar"> <NavMenu /> </div> <div class="main"> <div class="top-row px-4"> <AccessControl /> <a href="https://docs.microsoft.com/en-us/aspnet/" target="_blank">About</a> </div> <div class="content px-4"> @Body </div> </div>
これで認証機能の組み込み完了です!dotnet run
して動作確認してみましょう。
TOP画面です。未ログイン状態なので表示されなくなりました。ナビゲーションメニューからログイン画面に移動してみます。
Auth0のサインイン画面が表示されました。サインインするとTOPページにリダイレクトされ、今度はHello,world!の表示が見えるようになっています。
クイズの表示もバッチリです。無事にAuth0を使った認証機能を組み込むことができました。
まとめ
以前からBlazorに興味は合ったのですが、実際に触ったことが無かったので勉強する良い機会になりました。 最近はサーバーサイドもフロントもTypeScriptで開発することでコードを共有して...というスタイルが人気のようですが、今後Blazorが発展していけばサーバーサイドもフロントC#で開発するという選択肢も有力になってくるかもしれません。C#はLINQ等の強力な機能を持った良い言語だと思うので、今後もBlazorの動向を追いかけていきたいと思います。