この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
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の動向を追いかけていきたいと思います。