C#でSPAが開発できるBlazorにAuth0で認証機能を付けてみる

2020.02.05

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 を構築するためのフレームワークです。

ASP.NET Core Blazor の概要

そうです。なんと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.razorFetchData.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のダッシュボードからApplicationsCreate application と進み、Regular Web Applicationsを選択してアプリケーションを作成します。アプリケーションが作成できたら、SettingsからAllowed Callback URLshttps://localhost:5001/callbackを、Allowed Logout URLshttps://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.csConfigureServices()メソッドを修正します。

    				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.csConfigure()メソッドを修正します。

            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の動向を追いかけていきたいと思います。

参考