(27/30)みんなで学ぶBlazor:ユーザーとClaim機能の追加

(27/30)みんなで学ぶBlazor:ユーザーとClaim機能の追加

前述の通り、`ASP.NET Core Identity` は`Claim` ベースの認証であり、`Role` はタイプが`Role` の`Claim` です。

最終更新 2021/12/25 11:08
StrayaWorker
読了目安 9 分
カテゴリ
Blazor
テーマ
みんなで学ぶBlazorシリーズ
タグ
.NET C# ASP.NET Core Blazor

前に述べたように、ASP.NET Core IdentityClaim に基づく認証であり、Role は型が RoleClaim にすぎません。ASP.NET Framework Identity の時代には Role 認証しかありませんでしたが、ClaimASP.NET Core Identity で初めて登場したもので、FacebookTwitter などの外部プログラムのサードパーティ認可を取得するためのものです。これにより、ユーザーは異なるプラットフォームで同じアカウントを登録する必要がなくなります。

そして Claim は実際には単なる ClaimTypeClaimValue の文字列の組み合わせであり、通常は Role のように管理ページでユーザーに割り当てるのではなく、User ページで管理し、User 配下の Claim を追加・削除します。そこで今日は User ページを実装します。

まず同様に ViewModel とデータアクセス層が必要です。やっていることは同じなので、詳細は割愛します。

UserViewModel

namespace BlazorServer.ViewModels;

public class CustomUserViewModel
{
	public CustomUserViewModel()
	{
		Claims = new List<string>();
	}

	public string? UserId { get; set; }

	public string? UserName { get; set; }

	public string? Email { get; set; }

	public List<string>? Claims { get; set; }
}

単一の Claim を保持する ViewModel

namespace BlazorServer.ViewModels;

public class CustomUserClaimViewModel
{
	public string? ClaimType { get; set; }
	public bool IsSelected { get; set; }
}

User 配下の Claim を保持する ViewModel

namespace BlazorServer.ViewModels;

public class CustomUserClaimsViewModel
{
	public CustomUserClaimsViewModel()
	{
		Claims = new List<CustomUserClaimViewModel>();
	}

	public string? UserId { get; set; }

	public List<CustomUserClaimViewModel> Claims { get; set; }
}

ClaimUser のように最初から登録されているわけでもなく、Role のようにユーザーが自由に定義するものでもないため、ここではあらかじめ User 権限に関連するいくつかの Claim を用意します。

using System.Security.Claims;

namespace BlazorServer.Models;

public static class ClaimsStore
{
	public static List<Claim> AllClaims = new()
	{
		new Claim("ManageUser", string.Empty),
		new Claim("CreateUser", string.Empty),
		new Claim("EditUser", string.Empty),
		new Claim("DeleteUser", string.Empty)
	};
}

ページ IUserRepository

using BlazorServer.Models;
using BlazorServer.ViewModels;

namespace BlazorServer.Repository;

public interface IUserRepository
{
	Task<ResultViewModel> DeleteUserAsync(string userId);
	Task<ResultViewModel> EditUserAsync(CustomUserViewModel model);
	Task<CustomUserViewModel> GetUserAsync(string userId);
	Task<List<CustomUserViewModel>> GetUsersAsync();
	Task<CustomUserClaimsViewModel> EditClaimsInUserAsync(string userId);
	Task<ResultViewModel> EditClaimsInUserAsync(CustomUserClaimsViewModel model);
}

UserRepository の実装です。RoleRepository.EditUsersInRoleAsyncPost メソッドを覚えていれば、当時は Role.IdList<CustomUserRoleViewModel> model を別々の変数に格納していました。ここでの User 配下の Claim を編集する Post メソッドは Role とは異なり、別途 ViewModel CustomUserClaimsViewModel を使用してデータを保持しますが、本質的には違いはありません。

using System.Security.Claims;
using BlazorServer.Models;
using BlazorServer.ViewModels;
using Microsoft.AspNetCore.Identity;

namespace BlazorServer.Repository.Implement;

public class UserRepository : IUserRepository
{
	private readonly UserManager<IdentityUser> _userManager;

	public UserRepository(UserManager<IdentityUser> userManager)
	{
		_userManager = userManager;
	}

	public async Task<List<CustomUserViewModel>> GetUsersAsync()
	{
		var customUsers = _userManager.Users.Select(user => new CustomUserViewModel
			{ UserId = user.Id, UserName = user.UserName, Email = user.Email }).ToList();

		return await Task.Run(() => customUsers);
	}

	public async Task<CustomUserViewModel> GetUserAsync(string userId)
	{
		var user = await _userManager.FindByIdAsync(userId);
		var userClaims = await _userManager.GetClaimsAsync(user);
		var result = new CustomUserViewModel
		{
			UserId = user.Id,
			UserName = user.UserName,
			Email = user.Email,
			Claims = userClaims.Select(x => $"{x.Type} : {x.Value}").ToList()
		};
		return result;
	}

	public async Task<ResultViewModel> EditUserAsync(CustomUserViewModel model)
	{
		var user = await _userManager.FindByIdAsync(model.UserId);

		if (user == null)
		{
			return new ResultViewModel
			{
				Message = $"ID {model.UserId} のユーザーが見つかりません",
				IsSuccess = false
			};
		}

		user.UserName = model.UserName;
		user.Email = model.Email;
		var result = await _userManager.UpdateAsync(user);
		if (result.Succeeded)
		{
			return new ResultViewModel
			{
				Message = "ユーザーを更新しました!",
				IsSuccess = true
			};
		}

		return new ResultViewModel
		{
			Message = "ユーザーの更新に失敗しました!",
			IsSuccess = false
		};
	}

	public async Task<ResultViewModel> DeleteUserAsync(string userId)
	{
		var user = await _userManager.FindByIdAsync(userId);

		if (user == null)
		{
			return new ResultViewModel
			{
				Message = $"ID {userId} のユーザーが見つかりません",
				IsSuccess = false
			};
		}

		var result = await _userManager.DeleteAsync(user);
		if (result.Succeeded)
		{
			return new ResultViewModel
			{
				Message = "ユーザーを削除しました!",
				IsSuccess = true
			};
		}

		return new ResultViewModel
		{
			Message = "ユーザーの削除に失敗しました!",
			IsSuccess = false
		};
	}

	public async Task<CustomUserClaimsViewModel> EditClaimsInUserAsync(string userId)
	{
		var user = await _userManager.FindByIdAsync(userId);
		var claims = await _userManager.GetClaimsAsync(user);
		var model = new CustomUserClaimsViewModel
		{
			UserId = userId
		};

		foreach (var claim in ClaimsStore.AllClaims)
		{
			var userClaim = new CustomUserClaimViewModel
			{
				ClaimType = claim.Type
			};

			if (claims.Any(c => c.Type == claim.Type && c.Value == "true"))
			{
				userClaim.IsSelected = true;
			}

			model.Claims.Add(userClaim);
		}

		return model;
	}

	public async Task<ResultViewModel> EditClaimsInUserAsync(CustomUserClaimsViewModel model)
	{
		var user = await _userManager.FindByIdAsync(model.UserId);
		var claims = await _userManager.GetClaimsAsync(user);
		var result = await _userManager.RemoveClaimsAsync(user, claims);

		if (!result.Succeeded)
		{
			return new ResultViewModel
			{
				Message = "ユーザーの Claim を削除できません!",
				IsSuccess = false
			};
		}

		result = await _userManager.AddClaimsAsync(user,
			model.Claims.Select(c => new Claim(c.ClaimType!, c.IsSelected ? "true" : "false")));

		if (!result.Succeeded)
		{
			return new ResultViewModel
			{
				Message = "指定された Claim をユーザーに割り当てられません!",
				IsSuccess = false
			};
		}

		return new ResultViewModel
		{
			Message = "Claim の割り当てに成功しました",
			IsSuccess = true
		};
	}
}

Program.cs で登録します。

builder.Services.AddScoped<IUserRepository, UserRepository>();

次にフロントエンドのページを表示します。

UserManagement.razor.cs

using BlazorServer.Repository;
using BlazorServer.Shared;
using BlazorServer.ViewModels;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using JsonSerializer = System.Text.Json.JsonSerializer;

namespace BlazorServer.Pages.RolesManagement;

public partial class UserManagement
{
	[Inject] protected IUserRepository? UserRepository { get; set; }
	[Inject] protected NavigationManager? NavigationManager { get; set; }
	[Inject] protected IJSRuntime? Js { get; set; }
	private JsInteropClasses? _jsClass;
	public List<CustomUserViewModel> Users { get; set; } = new();

	protected override async Task OnInitializedAsync()
	{
		await LoadData();
		_jsClass = new JsInteropClasses(Js!);
	}

	private async Task LoadData()
	{
		Users = await UserRepository!.GetUsersAsync();
	}

	private async Task EditUser(string userId)
	{
		NavigationManager!.NavigateTo($"UserManagement/EditUser/{userId}");
		await Task.CompletedTask;
	}

	private async Task DeleteUser(string userId)
	{
		var sweetConfirm = new SweetConfirmViewModel()
		{
			RequestTitle = $"ユーザー {userId} を削除してもよろしいですか?",
			RequestText = "この操作は元に戻せません",
			ResponseTitle = "削除成功",
			ResponseText = "ユーザーが削除されました",
		};
		var jsonString = JsonSerializer.Serialize(sweetConfirm);
		var result = await _jsClass!.Confirm(jsonString);
		if (result)
		{
			var deleted = await UserRepository!.DeleteUserAsync(userId);
			if (deleted.IsSuccess)
			{
				await LoadData();
			}
			else
			{
				await _jsClass!.Alert(deleted.Message!);
			}
		}
	}
}

UserManagement.razor

@page "/UserManagement/UserList"

<h1>すべてのユーザー</h1>

@if (Users.Any()) {
<NavLink
  class="btn btn-primary mb-3"
  href="Identity/Account/Register"
  Match="NavLinkMatch.All"
>
  新規ユーザー追加
</NavLink>

foreach (var user in Users) {
<div class="card mb-3 w-25">
  <div class="card-header">ユーザーID : @user.UserId</div>
  <div class="card-body">
    <h5 class="card-title">@user.UserName</h5>
  </div>
  <div class="card-footer">
    <button
      type="button"
      class="btn btn-primary"
      @onclick="() => EditUser(user.UserId)"
    >
      ユーザー編集
    </button>
    <button
      type="button"
      class="btn btn-danger"
      @onclick="() => DeleteUser(user.UserId)"
    >
      ユーザー削除
    </button>
  </div>
</div>
} } else {
<div class="card w-25">
  <div class="card-header">まだユーザーがいません</div>
  <div class="card-body">
    <h5 class="card-title">下のボタンをクリックしてユーザーを追加</h5>
    <NavLink
      class="btn btn-primary"
      href="Identity/Account/Register"
      Match="NavLinkMatch.All"
    >
      新規ユーザー追加
    </NavLink>
  </div>
</div>
}

EditUser.razor.cs

using BlazorServer.Repository;
using BlazorServer.ViewModels;
using Microsoft.AspNetCore.Components;

namespace BlazorServer.Pages.RolesManagement;

public partial class EditUser
{
	[Inject] protected IUserRepository? UserRepository { get; set; }
	[Inject] protected NavigationManager? NavigationManager { get; set; }
	public CustomUserViewModel User { get; set; } = new();
	[Parameter] public string? UserId { get; set; }

	protected override async Task OnInitializedAsync()
	{
		var result = await UserRepository!.GetUserAsync(UserId!);
		User = new CustomUserViewModel
		{
			UserId = result.UserId,
			UserName = result.UserName,
			Claims = result.Claims
		};
	}

	private async Task EditRole()
	{
		await UserRepository!.EditUserAsync(User);
		NavigationManager!.NavigateTo("/UserManagement/UserList");
	}

	public void EditUsersInRole()
	{
		NavigationManager!.NavigateTo($"/UserManagement/EditClaimsInUser/{UserId}");
	}

	public void Cancel()
	{
		NavigationManager!.NavigateTo($"/UserManagement/UserList");
	}
}

EditUser.razor

@page "/UserManagement/EditUser/{UserId}"

<EditForm class="mt-3" Model="User" OnValidSubmit="EditRole">
  <DataAnnotationsValidator />
  <ValidationSummary />
  <div class="form-group row">
    <label for="RoleName" class="col-sm-1 col-form-label">ユーザー名</label>
    <div class="col-sm-3">
      <InputText
        @bind-Value="User.UserName"
        id="RoleName"
        class="form-control"
        placeholder="ユーザー名"
      ></InputText>
    </div>
  </div>

  <div class="card mb-3 w-50">
    <div class="card-header">
      <h3>ユーザー配下の Claim</h3>
    </div>
    <div class="card-body">
      @if (User.Claims.Any()) { foreach (var claim in User.Claims) {
      <h5 class="card-title">@claim</h5>
      } } else {
      <h5 class="card-title">現在このユーザーに Claim はありません</h5>
      }
    </div>
    <div class="card-footer">
      <button type="submit" class="btn btn-primary">ユーザー更新</button>
      <button type="button" class="btn btn-info" @onclick="EditUsersInRole">
        このユーザーの Claim を追加/削除
      </button>
      <button type="button" class="btn btn-danger" @onclick="Cancel">
        キャンセル
      </button>
    </div>
  </div>
</EditForm>

EditClaimsInUser.razor.cs

using BlazorServer.Repository;
using BlazorServer.Shared;
using BlazorServer.ViewModels;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;

namespace BlazorServer.Pages.RolesManagement;

public partial class EditClaimsInUser
{
	[Inject] protected IUserRepository? UserRepository { get; set; }
	[Inject] protected NavigationManager? NavigationManager { get; set; }
	[Inject] protected IJSRuntime? Js { get; set; }
	private JsInteropClasses? _jsClass;
	[Parameter] public string? UserId { get; set; }
	public CustomUserClaimsViewModel UserClaimViewModel { get; set; } = new CustomUserClaimsViewModel();

	protected override async Task OnInitializedAsync()
	{
		await LoadData();
		_jsClass = new JsInteropClasses(Js!);
	}

	private async Task LoadData()
	{
		UserClaimViewModel = (await UserRepository!.EditClaimsInUserAsync(UserId!));
	}


	public async Task HandleValidSubmit()
	{
		var result = await UserRepository!.EditClaimsInUserAsync(UserClaimViewModel);

		if (result.IsSuccess)
		{
			NavigationManager!.NavigateTo($"/UserManagement/EditUser/{UserId}");
		}
		else
		{
			await _jsClass!.Alert(result.Message!);
		}
	}

	public void Cancel()
	{
		NavigationManager!.NavigateTo($"/UserManagement/EditUser/{UserId}");
	}
}

EditClaimsInUser.razor

@page "/UserManagement/EditClaimsInUser/{UserId}"

<EditForm Model="UserClaimViewModel" OnValidSubmit="HandleValidSubmit">
  <DataAnnotationsValidator />
  <ValidationSummary />
  <div class="card">
    <div class="card-header">
      <h2>ユーザーから Claim を追加または削除</h2>
    </div>
    <div class="card-body">
      @foreach (var claim in UserClaimViewModel.Claims) {
      <div class="form-check m-1">
        <label class="form-check-label">
          <InputCheckbox @bind-Value="@claim.IsSelected"></InputCheckbox>
          @claim.ClaimType
        </label>
      </div>
      }
    </div>
    <div class="card-footer">
      <button type="submit" class="btn btn-primary">更新</button>
      <button type="button" class="btn btn-danger" @onclick="@Cancel">
        キャンセル
      </button>
    </div>
  </div>
</EditForm>

最後に NavMenu.razorNavLink を追加します。

<li class="nav-item px-3">
  <NavLink
    class="nav-link"
    href="UserManagement/UserList"
    Match="NavLinkMatch.All"
  >
    <span class="bi bi-people h4 p-2 mb-0" aria-hidden="true"></span> Users
  </NavLink>
</li>

これで、UserClaim の簡単な CRUD ページができました。

参照:

  1. Manage user claims in asp net core
  2. Claim type and claim value in claims policy based authorization in asp net core

注:本記事のコードは .NET 6 + Visual Studio 2022 でリファクタリングされています。原文のリンクとリファクタリング後のコードを比較しながら学習してください。お読みいただきありがとうございます。原作者をサポートしてください。

さらに探索

関連読書

その他の記事
同じカテゴリ / 同じタグ 2021/12/25

(29/30)みんなで学ぶBlazor:Blazor単体テスト

システム開発において最も退屈なプロセスは、おそらくバグ修正です。特に、null オブジェクトにアクセスしようとするエラー(`Object reference not set to an instance of an object.`)は、多くの初心者が最初に直面する問題です。退屈なバグ修正から解放されるために、この記事では「単体テスト」を紹介します。

続きを読む
同じカテゴリ / 同じタグ 2021/12/25

(28/30)みんなで学ぶBlazor:ポリシーベースの認可

以前に「ASP.NET Core Identity」は「Claim」ベースの検証を使用すると述べましたが、実は「ASP.NET Core Identity」には異なる種類の認可方法があります。最も簡単な「ログイン認可」「ロール認可」「Claim認可」ですが、これらはすべて同じ方法で実現されています:原則認可(ポリシーベースの認可)です。

続きを読む