📘

簡単に .NET 8 の Blazor にオレオレ ログイン機能を付けよう

2024/03/17に公開

1 つ前の記事で結構丁寧に初期状態からステップ バイ ステップでオレオレログイン機能をつける方法を紹介しました。

https://zenn.dev/microsoft/articles/aspnetcore-blazor-dotnet8-tryaddauth

この記事では、プロジェクトテンプレートで認証を構成した状態から、いらないものを削除して独自ログイン機能の状態にする方法を紹介します。

プロジェクトの新規作成

Blazor Web App プロジェクトテンプレートを使ってプロジェクトを新規作成します。このとき Authentication Type に Individual Accounts を選択することで認証が構成されたプロジェクトが作成されます。

このプロジェクトはサーバーサイドのプロジェクトに Microsoft.AspNetCore.Identity.EntityFrameworkCore などのパッケージがあることから ASP.NET Core Identity で EF Core を使ってユーザー情報を格納するようになっています。そして Components/Account フォルダ配下にものすごいたくさんのページが作成されています。2 段階認証とか etc... 諸々のフルセットが用意されています。

不要なものを削除

では、不要なものを削除していきます。1 度でもゼロからログイン機能に必要なプロジェクトをセットアップしたことがあれば不要なものを消すのも勘が働くので簡単です。

まずは Components/Account フォルダから PersistingRevalidatingAuthenticationStateProvider.cs 以外をサクッと消します。UserInfo.csPersistingRevalidatingAuthenticationStateProvider.cs からセキュリティスタンプのチェック機能を削ったり認証したときに扱う情報を名前とロールになるように修正しておきます。

UserInfo.cs
namespace OreoreAuthApp2.Client;

// Add properties to this class and update the server and client AuthenticationStateProviders
// to expose more information about the authenticated user to the client.
public class UserInfo
{
    public required string Name { get; set; } // UserId を Name に
    public required string Role { get; set; } // email を Role に
}

差分の参考用に元のコードをコメントにして残してます。

PersistingRevalidatingAuthenticationStateProvider.cs
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Components.Web;
//using Microsoft.AspNetCore.Identity;
//using Microsoft.Extensions.Options;
using OreoreAuthApp2.Client;
//using OreoreAuthApp2.Data;
using System.Diagnostics;
using System.Security.Claims;

namespace OreoreAuthApp2.Components.Account;
// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user
// every 30 minutes an interactive circuit is connected. It also uses PersistentComponentState to flow the
// authentication state to the client which is then fixed for the lifetime of the WebAssembly application.
//internal sealed class PersistingRevalidatingAuthenticationStateProvider : RevalidatingServerAuthenticationStateProvider
internal sealed class PersistingRevalidatingAuthenticationStateProvider : ServerAuthenticationStateProvider, IDisposable
{
    //private readonly IServiceScopeFactory scopeFactory;
    private readonly PersistentComponentState state;
    //private readonly IdentityOptions options;

    private readonly PersistingComponentStateSubscription subscription;

    private Task<AuthenticationState>? authenticationStateTask;

    public PersistingRevalidatingAuthenticationStateProvider(
        //ILoggerFactory loggerFactory,
        //IServiceScopeFactory serviceScopeFactory,
        PersistentComponentState persistentComponentState)
        //IOptions<IdentityOptions> optionsAccessor)
        //: base(loggerFactory)
    {
        //scopeFactory = serviceScopeFactory;
        state = persistentComponentState;
        //options = optionsAccessor.Value;

        AuthenticationStateChanged += OnAuthenticationStateChanged;
        subscription = state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly);
    }

    //protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);

    //protected override async Task<bool> ValidateAuthenticationStateAsync(
    //    AuthenticationState authenticationState, CancellationToken cancellationToken)
    //{
    //    // Get the user manager from a new scope to ensure it fetches fresh data
    //    await using var scope = scopeFactory.CreateAsyncScope();
    //    var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
    //    return await ValidateSecurityStampAsync(userManager, authenticationState.User);
    //}

    //private async Task<bool> ValidateSecurityStampAsync(UserManager<ApplicationUser> userManager, ClaimsPrincipal principal)
    //{
    //    var user = await userManager.GetUserAsync(principal);
    //    if (user is null)
    //    {
    //        return false;
    //    }
    //    else if (!userManager.SupportsUserSecurityStamp)
    //    {
    //        return true;
    //    }
    //    else
    //    {
    //        var principalStamp = principal.FindFirstValue(options.ClaimsIdentity.SecurityStampClaimType);
    //        var userStamp = await userManager.GetSecurityStampAsync(user);
    //        return principalStamp == userStamp;
    //    }
    //}

    private void OnAuthenticationStateChanged(Task<AuthenticationState> task)
    {
        authenticationStateTask = task;
    }

    private async Task OnPersistingAsync()
    {
        if (authenticationStateTask is null)
        {
            throw new UnreachableException($"Authentication state not set in {nameof(OnPersistingAsync)}().");
        }

        var authenticationState = await authenticationStateTask;
        var principal = authenticationState.User;

        if (principal.Identity?.IsAuthenticated == true)
        {
            //var userId = principal.FindFirst(options.ClaimsIdentity.UserIdClaimType)?.Value;
            //var email = principal.FindFirst(options.ClaimsIdentity.EmailClaimType)?.Value;

            //if (userId != null && email != null)
            //{
            //    state.PersistAsJson(nameof(UserInfo), new UserInfo
            //    {
            //        UserId = userId,
            //        Email = email,
            //    });
            //}

            var name = principal.FindFirst(ClaimTypes.Name)?.Value;
            var role = principal.FindFirst(ClaimTypes.Role)?.Value;

            if (name != null && role != null)
            {
                state.PersistAsJson(nameof(UserInfo), new UserInfo
                {
                    Name = name,
                    Role = role,
                });
            }

        }
    }

    //protected override void Dispose(bool disposing)
    //{
    //    subscription.Dispose();
    //    AuthenticationStateChanged -= OnAuthenticationStateChanged;
    //    base.Dispose(disposing);
    //}
    public void Dispose()
    {
        subscription.Dispose();
        AuthenticationStateChanged -= OnAuthenticationStateChanged;
    }
}

クライアント側のプロジェクトの PersistentAuthenticationStateProvider.csUserInfo.cs の内容にあわせて以下のように修正します。

PersistentAuthenticationStateProvider.cs
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using System.Security.Claims;

namespace OreoreAuthApp2.Client;
// This is a client-side AuthenticationStateProvider that determines the user's authentication state by
// looking for data persisted in the page when it was rendered on the server. This authentication state will
// be fixed for the lifetime of the WebAssembly application. So, if the user needs to log in or out, a full
// page reload is required.
//
// This only provides a user name and email for display purposes. It does not actually include any tokens
// that authenticate to the server when making subsequent requests. That works separately using a
// cookie that will be included on HttpClient requests to the server.
internal class PersistentAuthenticationStateProvider : AuthenticationStateProvider
{
    private static readonly Task<AuthenticationState> defaultUnauthenticatedTask =
        Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())));

    private readonly Task<AuthenticationState> authenticationStateTask = defaultUnauthenticatedTask;

    public PersistentAuthenticationStateProvider(PersistentComponentState state)
    {
        if (!state.TryTakeFromJson<UserInfo>(nameof(UserInfo), out var userInfo) || userInfo is null)
        {
            return;
        }

        Claim[] claims = [
            new Claim(ClaimTypes.Name, userInfo.Name),
            new Claim(ClaimTypes.Role, userInfo.Role) ];

        authenticationStateTask = Task.FromResult(
            new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims,
                authenticationType: nameof(PersistentAuthenticationStateProvider)))));
    }

    public override Task<AuthenticationState> GetAuthenticationStateAsync() => authenticationStateTask;
}

次にサーバー側のプロジェクトの Data フォルダを削除します。

そして、サーバー側のプロジェクトから以下のパッケージへの参照を削除します。

<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.3" />

参照を削除したのでサーバー側のプロジェクトの Program.cs でコンパイルエラーがいくつか出ています。これを修正して以下のような感じにします。

Program.cs
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
//using Microsoft.EntityFrameworkCore;
//using OreoreAuthApp2.Client.Pages;
using OreoreAuthApp2.Components;
using OreoreAuthApp2.Components.Account;
//using OreoreAuthApp2.Data;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents();

builder.Services.AddCascadingAuthenticationState();
//builder.Services.AddScoped<IdentityUserAccessor>();
//builder.Services.AddScoped<IdentityRedirectManager>();
builder.Services.AddScoped<AuthenticationStateProvider, PersistingRevalidatingAuthenticationStateProvider>();

//builder.Services.AddAuthentication(options =>
//    {
//        options.DefaultScheme = IdentityConstants.ApplicationScheme;
//        options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
//    })
//    .AddIdentityCookies();

//var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
//builder.Services.AddDbContext<ApplicationDbContext>(options =>
//    options.UseSqlServer(connectionString));
//builder.Services.AddDatabaseDeveloperPageExceptionFilter();

//builder.Services.AddIdentityCore<ApplicationUser>(options => options.SignIn.RequireConfirmedAccount = true)
//    .AddEntityFrameworkStores<ApplicationDbContext>()
//    .AddSignInManager()
//    .AddDefaultTokenProviders();

// 認証関連の設定を追加
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseWebAssemblyDebugging();
    //app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    // 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.UseAuthentication();
app.UseAuthorization();

app.UseAntiforgery();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode()
    .AddInteractiveWebAssemblyRenderMode()
    .AddAdditionalAssemblies(typeof(OreoreAuthApp2.Client._Imports).Assembly);

// Add additional endpoints required by the Identity /Account Razor components.
//app.MapAdditionalIdentityEndpoints();

app.Run();

ログインページの追加

サーバー側のプロジェクトに Components/Account/Login.razor を追加します。このプロジェクトでは Account/Login?returnUrl=xxxxxx のような URL がログインページになっている前提のコードになっているので、それに合わせて以下のような感じでログインページを作成します。

Login.razor
@page "/Account/Login"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authentication.Cookies
@using System.Security.Claims
@inject NavigationManager NavigationManager

<h3>Login</h3>

<EditForm EditContext="EditContext" FormName="login-form" OnValidSubmit="LoginAsync">
    <DataAnnotationsValidator />
    <ValidationSummary Model="Model" />
    <div>
        <label>
            ユーザー名:
            <InputText @bind-Value="Model.UserName" />
            <ValidationMessage For="() => Model.UserName" />
        </label>
    </div>
    <div>
        <label>
            パスワード:
            <InputText @bind-Value="Model.Password" type="password" />
            <ValidationMessage For="() => Model.Password" />
        </label>
    </div>
    <div>
        <button type="submit">ログイン</button>
    </div>
</EditForm>

@code {
    [SupplyParameterFromForm]
    private LoginForm Model { get; set; } = default!;
    private EditContext EditContext { get; set; } = default!;
    private ValidationMessageStore ValidationMessageStore { get; set; } = default!;

    [CascadingParameter]
    private HttpContext? HttpContext { get; set; }

    // ログイン後のページのURL
    [SupplyParameterFromQuery(Name = "returnUrl")]
    public string? ReturnUrl { get; set; }

    protected override void OnInitialized()
    {
        Model ??= new();
        EditContext = new(Model);
        ValidationMessageStore = new(EditContext);
    }

    private async Task LoginAsync(EditContext editContext)
    {
        _ = HttpContext ?? throw new InvalidOperationException("Static SSR で実行してください。");

        ClaimsIdentity? identity = null;
        // 本来であればここでログイン処理をするような機能を呼び出す
        if (Model.UserName == "admin" && Model.Password == "P@ssw0rd!")
        {
            identity = new(
                [new Claim(ClaimTypes.Name, Model.UserName), new Claim(ClaimTypes.Role, "Administrator")],
                CookieAuthenticationDefaults.AuthenticationScheme);
        }
        else if (Model.UserName == "user" && Model.Password == "P@ssw0rd!")
        {
            identity = new(
                [new Claim(ClaimTypes.Name, Model.UserName), new Claim(ClaimTypes.Role, "User")],
                CookieAuthenticationDefaults.AuthenticationScheme);
        }

        if (identity != null)
        {
            // ログイン成功!
            await HttpContext.SignInAsync(
                CookieAuthenticationDefaults.AuthenticationScheme,
                new ClaimsPrincipal(identity)
            );
            NavigationManager.NavigateTo(
                ReturnUrl ?? "",
                forceLoad: true
            );
        }
        else
        {
            // ログイン失敗
            ValidationMessageStore.Add(EditContext.Field(""), "ログインに失敗しました。");
        }
    }

    class LoginForm
    {
        [Required(ErrorMessage = "ユーザー名を入力してください。")]
        public string UserName { get; set; } = "";
        [Required(ErrorMessage = "パスワードを入力してください。")]
        public string Password { get; set; } = "";
    }
}

オプション: いらない項目をメニューから削除

サーバー側のプロジェクトにある Components/Layout/NavMenu.razor からいらないメニューを削除します。

NavMenu.razor
@implements IDisposable

@inject NavigationManager NavigationManager

<div class="top-row ps-3 navbar navbar-dark">
    <div class="container-fluid">
        <a class="navbar-brand" href="">OreoreAuthApp2</a>
    </div>
</div>

<input type="checkbox" title="Navigation menu" class="navbar-toggler" />

<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
    <nav class="flex-column">
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                <span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
            </NavLink>
        </div>

        <div class="nav-item px-3">
            <NavLink class="nav-link" href="counter">
                <span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter
            </NavLink>
        </div>

        <div class="nav-item px-3">
            <NavLink class="nav-link" href="weather">
                <span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
            </NavLink>
        </div>

        <div class="nav-item px-3">
            <NavLink class="nav-link" href="auth">
                <span class="bi bi-lock-nav-menu" aria-hidden="true"></span> Auth Required
            </NavLink>
        </div>

        <AuthorizeView>
            <Authorized>
@*                 <div class="nav-item px-3">
                    <NavLink class="nav-link" href="Account/Manage">
                        <span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span> @context.User.Identity?.Name
                    </NavLink>
                </div>
 *@                <div class="nav-item px-3">
                    <form action="Account/Logout" method="post">
                        <AntiforgeryToken />
                        <input type="hidden" name="ReturnUrl" value="@currentUrl" />
                        <button type="submit" class="nav-link">
                            <span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
                        </button>
                    </form>
                </div>
            </Authorized>
            <NotAuthorized>
@*                 <div class="nav-item px-3">
                    <NavLink class="nav-link" href="Account/Register">
                        <span class="bi bi-person-nav-menu" aria-hidden="true"></span> Register
                    </NavLink>
                </div>
 *@                <div class="nav-item px-3">
                    <NavLink class="nav-link" href="Account/Login">
                        <span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login
                    </NavLink>
                </div>
            </NotAuthorized>
        </AuthorizeView>
    </nav>
</div>

@code {
    private string? currentUrl;

    protected override void OnInitialized()
    {
        currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
        NavigationManager.LocationChanged += OnLocationChanged;
    }

    private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
    {
        currentUrl = NavigationManager.ToBaseRelativePath(e.Location);
        StateHasChanged();
    }

    public void Dispose()
    {
        NavigationManager.LocationChanged -= OnLocationChanged;
    }
}

ログアウトは作るの簡単なので作っちゃいましょう。/Account/Logout?

Program.cs
// ログアウト
app.MapPost("/Account/Logout", async (HttpContext httpContext, [FromForm]string? returnUrl) =>
{
    await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    return TypedResults.Redirect($"~/{returnUrl}");
});

これで以下のような感じで動くようになります。

まとめ

ということで、1つ前の記事でゼロからログイン機能を追加する方法を紹介しましたが、今回は認証が構成されたプロジェクトテンプレートをベースに自分で最低限のログイン機能を持った形にする方法を紹介しました。

Microsoft (有志)

Discussion