BlazorでシンプルにCookie認証 (.Net 8)

## １．この記事の目的
- 以前 Blazor + JWT認証 をまとめました
https://qiita.com/masayahak/items/e54ff025d29b3caf85bc
- こちらはもっとシンプルに Blazor + Cookie認証 です

## ２．前提条件
- Visual studio 2022 Version 17.12.2
- .Net 8
- UI：Blazor Web App
- Blazor rendermode： Static Server + InteractiveServerRenderMode
- WebAPIを利用しない
- 認証・認可にはCookieを利用
- 認証ロジックはここでは単純化し、あえてデータベースを利用しない


## ３．ソリューションのソース
https://github.com/masayahak/BlazorCookie

## ４．求めている挙動

### ４．１ ログインの流れ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/5d70dd08-271b-7e28-3b7e-c890300d381a.png)

ログイン成功
↓
- Cookieが作成される
- Blazorのフォーム上でユーザー名や権限（ロール）を取得できる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/9babbc11-f07b-574a-e0e4-bc0231da2830.png)

### ４．２ ログインユーザーのみ表示できるページの挙動
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/7c4420c8-c184-f3d6-2fa5-ce6fdca9eed0.png)

weatherページには ```@attribute [Authorize]```を指定してる。
```html
@page "/weather"
@attribute [Authorize]
```

ログアウトした状態（Cookieが存在しない）で 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/d118f648-705f-7abc-218e-333545c7bb3f.png)

```https://localhost:7022/weather```をGETすると
ログインページへリダイレクトされる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/9b43154f-437e-8b12-24a9-157e8a1898fa.png)



## ５．認証方法

### ５．１ 利用する認証スキーム
- AuthenticationHttpContextExtensions.SignInAsync
（ ASP.Net Coreの既定の認証スキーム ）
https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.aspnetcore.authentication.authenticationhttpcontextextensions.signinasync?view=aspnetcore-9.0

### ５．２ SignInAsyncを呼び出す認証サービスの作成

```C#
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;

namespace BlazorCookie.Services
{
    public class AuthenticationService
    {
        private readonly IHttpContextAccessor _httpContextAccessor;

        public AuthenticationService(IHttpContextAccessor httpContextAccessor)
        {
            _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
        }

        public async Task<bool> LoginAsync(LoginUser loginUser)
        {

            var claims = new List<Claim>();

            // 本来の認証判定はもっと複雑だが、ここではテスト用にシンプルに認証
            if (loginUser.UserName == "admin@test.com" && loginUser.Password == "test")
            {
                claims.Add(new Claim(ClaimTypes.Name, loginUser.UserName));
                claims.Add(new Claim(ClaimTypes.Role, "Administrator"));
            }
            else if (loginUser.UserName == "user@test.com" && loginUser.Password == "test")
            {
                claims.Add(new Claim(ClaimTypes.Name, loginUser.UserName));
                claims.Add(new Claim(ClaimTypes.Role, "User"));
            }
            else
            {
                return false;
            }

            var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);

            var httpContext = _httpContextAccessor.HttpContext;
            if (httpContext == null)
                throw new InvalidOperationException("HttpContext is not available.");

            await httpContext.SignInAsync(
                CookieAuthenticationDefaults.AuthenticationScheme,
                new ClaimsPrincipal(identity));
            return true;

        }
        public async Task LogoutAsync()
        {
            var httpContext = _httpContextAccessor.HttpContext;
            if (httpContext == null)
                throw new InvalidOperationException("HttpContext is not available.");

            await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        }
    }
    public class LoginUser
    {
        public string UserName { get; set; } = "";
        public string Password { get; set; } = "";
    }

}
```

### ５．３ ログインページ
```html
@page "/login"
@inject NavigationManager NavigationManager
@inject AuthenticationService AuthService
@attribute [AllowAnonymous]
@* rendermodeは Static Server 固定 *@
<h3>Login</h3>

<p>admin@test.com / test</p>
<p>user@test.com / test</p>

<EditForm Model="@loginUser" FormName="login-form" OnValidSubmit="LoginAsync">
    <div>
        <div class="mb-3">
            <label class="form-label">ユーザー名</label>
            <InputText @bind-Value="loginUser.UserName" class="form-control"></InputText>
        </div>
        <div class="mb-3">
            <label class="form-label">パスワード</label>
            <InputText @bind-Value="loginUser.Password" class="form-control"></InputText>
        </div>
    </div>
    <button type="submit" class="btn btn-primary">ログイン</button>
</EditForm>

@code {
    [SupplyParameterFromForm]
    private LoginUser loginUser { get; set; } = new();

    private async Task LoginAsync()
    {
        var success = await AuthService.LoginAsync(loginUser);
        if (success)
        {
            NavigationManager.NavigateTo("", forceLoad: true);
        }
    }

}
```

### ５．４ ログアウトページ
```html
@page "/logout"
@inject AuthenticationService AuthService
@inject NavigationManager NavigationManager

@code {
    protected override async Task OnInitializedAsync()
    {
        await AuthService.LogoutAsync();

        NavigationManager.NavigateTo("", forceLoad: true);
    }
}
```
### ５．５ 認証状態を判定するサンプル（Homeページ）

```html
@page "/"
@attribute [AllowAnonymous]

<PageTitle>Home</PageTitle>

<h1>Home</h1>

<div class="mb-3">
    <AuthorizeView Roles="Administrator">
        <div class="mb-3">
            あなたのロールは "Administrator" です。
        </div>
    </AuthorizeView>
    <AuthorizeView Roles="User">
        <div class="mb-3">
            あなたのロールは "User" です。
        </div>
    </AuthorizeView>
    
    <AuthorizeView>
        <Authorized>
            <p>ログイン済み： @context.User.Identity!.Name</p>
            <a href="/logout">LogOut</a>
        </Authorized>
        <NotAuthorized>
            <div class="mb-3">
                <p>ログインしてください。</p>
                <a href="/login">Login</a>
            </div>

            <div class="mb-3">
                <p>テスト用：InteractiveServerのフォームではEXCEPTIONが発生する</p>
                <a href="/login-interactive">Login - InteracticeServer</a>
            </div>
        </NotAuthorized>
    </AuthorizeView>
</div>
```

## ６．注意点
:::note alert
- Loginページの rendermode は Static Server 一択
- InteractiveServer や WebAssermbly は利用できない
:::
色々試して納得できたので共有します。

### ６．１ まずはStatic ServerでLoginした場合の挙動

(1) loginページにユーザー名＋パスワードを入力しPOSTする
(2) POSTの応答ヘッダーに
　　set-cookie:.AspNetCore.Cookies=CfDJ8ODTl-is4Y5Im-eN4-9_d.....がある。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/061a16cd-e064-ffea-39b1-a805181d37e3.png)

(3) ログイン成功後、homeへのリダイレクト要求ヘッダーには当然このCookieが含まれる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/63659f4e-910b-7544-2163-ce05feaa8bae.png)

:::note 
ここまでの挙動は目的通り
:::


### ６．２ InteraciveServerでLoginした場合の挙動

(1) loginページにユーザー名＋パスワードを入力しSUBMITしても、POSTは発生しない
（ InteractiveServer であれば SignalRのバイナリ通信だけが発生する ）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/207da7f8-71da-ec80-6d22-56cbcacd4037.png)

(2) ログを見るとエラーが発生している
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546646/a0c9034a-20f9-0399-1f70-084230e0f60b.png)

(3) エラーメッセージを要約すると

:::note alert
- ログインページのGETはすでにレスポンスした
- ログインボタンのSUBMITはSignalRでの通信でPOSTやGETではない
- SignalRの通信内で、GETの応答ヘッダーに含めるCookieを発行するのはダメ！
:::
言われてみればその通りなので納得です。