【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ～その4～

##前回
[【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ～その1～](https://qiita.com/OXamarin/items/08a829d0ea77e2554231)
[【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ～その2～](https://qiita.com/OXamarin/items/e34de662ad8958dcdab4)
[【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ～その3～](https://qiita.com/OXamarin/items/b213fc001e9b003b2fb0)

の続きです。

前回は、`Cognito`を使用して登録されているユーザーでないとログインできないようにしました。
これだとまだ、とある問題を抱えていますが、その前に今回はログイン情報の保持をさせるようにしたいと思います。

##ログイン情報の保持
多くのサービスは以下の様な処理でログイン情報を保持しているかと思います。
今回はこの方式に則って、機能追加をしていこうと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172223/9957df0c-7dd1-925c-6b63-2f6a864e1308.png)

出典：[セキュリティ対策はばっちり？ セッションとCookieの違いとPHPでの使い方をご紹介！](https://amg-solution.jp/blog/7061)

###Blazorによるクッキーへの読み書き
結構調べてはみたんですが、今の所クッキーを扱う情報について見当たりませんでした。

####BlazorではHTTPContextは扱えない(?)
`.NET MVC Core`などでは`HTTPContext`を通すことによりセッションやクッキーを扱う事が出来ましたが、`Controllerクラス`からでないと扱う事が出来ません。それは`Blazor`でも同じなようですが、今のソリューションに`Controllerクラス`を追加するというのはいささか変（というか、それって`Blazor`にとってどういう意味があるの？）と思ったので別の方法を考えてみました。

####JavaScriptでクッキーを扱う
別の方法としては、`JavaScript`でクッキーを扱うしかないと思いました。`Blazor`のよさを損なうような感じがしますがここはしょうがない。実装してみましょう。

###呼び出したいJSの処理を追加する
`_Host.cshtmlファイル`に`WriteCookieメソッド`・`ReadCookieメソッド`を追加します。

```JavaScript:_Host.cshtml
<script>
    window.blazorExtensions = {
        WriteCookie: function(name, value) {
            var maxAge = "max-age=3600; ";
            var path = "path=/; ";
            document.cookie = name + "=" + value + "; " + maxAge + path;
        },

        ReadCookie: function () {
            return document.cookie;
        },
    }
</script>
```

####ハマりポイント
ここで`ReadCookieメソッド`はクッキー全体ではなく連想配列などで返したかったんですが、配列を生成するだけで`UnHundled Error`となってしまったので泣く泣くこうしています。配列を返す方法があれば教えてください。


###C#からJavaScriptの処理を呼ぶ
新しく、`Serviceフォルダ`・`CookieServiceクラス`を追加しました。
というのも、クッキー操作をしたいのは特定のページだけでない可能性があると考えたからです。複数回インスタンス生成するようなクラスでもないですし、`DI`でどのページでも使いまわせるようにしましょう、という粋な計らいです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172223/d3605655-8912-f581-7af4-ee39221a7a98.png)

コードは以下の通りです。

```C#:CookieService.cs
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.JSInterop;

namespace LoginTest.Service
{
    public class CookieService
    {
        #region フィールド
        private readonly IJSRuntime _jsRuntime;
        #endregion

        #region コンストラクタ
        public CookieService(IJSRuntime jsRuntime)
        {
            _jsRuntime = jsRuntime;
        }
        #endregion

        #region メソッド
        /// <summary>
        /// クッキーへの書き込み
        /// </summary>
        /// <param name="key"></param>
        /// <param name="value"></param>
        /// <returns></returns>
        public async Task WriteCookieAsync(string key, string value)
        {
            await _jsRuntime.InvokeVoidAsync("blazorExtensions.WriteCookie", key, value).ConfigureAwait(false);
        }
        /// <summary>
        /// クッキーの読み込み
        /// </summary>
        /// <returns></returns>
        public async Task<Dictionary<string, string>> ReadCookieAsync()
        {
            var cookieDictionary = new Dictionary<string, string>();
            var cookie = await _jsRuntime.InvokeAsync<string>("blazorExtensions.ReadCookie").ConfigureAwait(false);

            var cookieSplit = cookie.Split(";");
            for (var i = 0; i < cookieSplit.Length; ++i)
            {
                var cookieKeyValue = cookie.Split("=");
                cookieDictionary.TryAdd(cookieKeyValue[0], cookieKeyValue[1]);
            }

            return cookieDictionary;
        }
        #endregion
    }
}
```
`IJSRuntimeクラス`の`InvokeAsyncメソッド`に対して、先ほど宣言した`JavaScript`のメソッド名や引数を渡してあげるだけで実行ができます。かんたんかんたん。

あとは`DI`できるように、`Startupクラス`の`ConfigureServicesメソッド`に`CookieServiceクラス`を`Scoped`で追加してあげるだけでおｋです。

```C#:Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddSignalR();
    services.AddRazorPages();
    services.AddServerSideBlazor();
    services.AddResponseCompression(opts =>
    {
        opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
            new[] { "application/octet-stream" });
    });
    services.AddBlazoredLocalStorage();
    services.AddScoped<CookieService>(); //★この行を追加★
}
```


####ハマりポイント
と、実装は非常に簡単に見えますが、この処理に辿り着くまでにかなり時間が掛かりました。ハマりポイントは2点あります。

**①サービスクラスへのDIはコンストラクタインジェクションしかできない**
これまでは何となく`Inject属性`を付けていればサービスに対して`DI`出来ていましたが、それは`razorファイル`や`razor.csファイル`だったから、だそうです。

それについては以下のページに記載があります。
[サービスでDIを使用する](https://docs.microsoft.com/ja-jp/aspnet/core/blazor/fundamentals/dependency-injection?view=aspnetcore-3.1#use-di-in-services)

なのでこの`CookieServiceクラス`ではコンストラクタインジェクションを行って`IJSRuntimeクラス`への注入をしています。仮に、プロパティで`IJSRuntimeクラス`を宣言し`Inject属性`を付けたとしても`null`のままで値は何も入ってきません。

---
**②サービスクラスでデフォルトサービスをDIさせたい場合はデフォルトサービスの有効期間に合わせてサービスを追加する**
ちょっと何を言っているか分からない人が多くなってきてそうなので丁寧めに説明します。

サービスクラスというのは、今回作ろうとしている`CookieServiceクラス`のことです。
デフォルトサービスというのは、以下の3つのサービスの事を指します。最初から使えるサービス達ですね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172223/676c2681-e6d8-37c5-2fd3-5a0fa045ece0.png)

今回、私はサーバーサイドでソリューションを作成しています。
また、デフォルトサービスである`IJSRuntimeクラス`を使用したいと考えています。
上記の画像を見てみればわかりますが、サーバーサイドでは`IJSRuntimeクラス`の有効期間は「スコープ」となっています。
なので以下の様に、`CookieServiceクラス`を「スコープ」で追加しています。

```C#:Startup.cs
services.AddScoped<CookieService>(); //★この行を追加★
```

ここを合わせる必要があると分かっていなかったので最初はシングルトンで追加していました。そうすると以下のようなエラーが出力されました。

![キャプチャ.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172223/d92e228d-3a20-e7e0-85ac-83e1ccb6455b.jpeg)

>InvalidOperationException: Cannot consume scoped service 'Microsoft.JSInterop.IJSRuntime' from singleton 'LoginTest.Service.CookieService'.
(InvalidOperationException：シングルトン「LoginTest.Service.CookieService」からスコープサービス「Microsoft.JSInterop.IJSRuntime」を使用できません。)

サービスの有効期間についての説明はいかの通りです。スコープはサーバーサイドにしかない有効期間なのですね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172223/05916476-9339-73e3-ff39-06413c4334b1.png)

###ログイン情報を使いまわす
クッキーやローカルストレージを利用して、ログイン情報を保持します。また。ログイン情報が保存されているのであれば自動的にチャットページへルーティングしてあげましょう。

まずは`NuGet`から`Blazored.LocalStorage`をインストールします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172223/b3f3f46c-f931-fcc2-243e-43bd74725cc9.png)

このライブラリを使用することにより、非常に簡単にローカルストレージを使用することができます。ローカルストレージであればブラウザを閉じたり別タブでも保存している情報が引き継げるので今回はローカルストレージを使用します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172223/791d484d-0635-ce0e-7286-1574d4868969.png)
出典：[JavaScriptのsessionStorageの使い方を現役エンジニアが解説【初心者向け】](https://techacademy.jp/magazine/32870#:~:text=%E3%81%BB%E3%81%A8%E3%82%93%E3%81%A9%E5%90%8C%E3%81%98%E3%81%A7%E3%80%81%E4%BF%9D%E5%AD%98%E3%82%84,%E9%96%89%E3%81%98%E3%81%A6%E3%82%82%E6%9C%89%E5%8A%B9%E3%81%A7%E3%81%99%E3%80%82)

---
改めて、以下の図を思い出してみましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172223/9957df0c-7dd1-925c-6b63-2f6a864e1308.png)

ユーザーがページに訪れた際に行うべき処理は次の通りです。

1. ログインページへアクセス
2. ユーザーのクッキーに保存されているセッションIDを取得
3. クッキーのセッションIDと一致するローカルストレージに保持されているセッションIDを見つける
4. ローカルストレージ内に保持している前回ログイン情報を取得
5. チャットページへルーティングする

クッキーにセッションIDが保存されていなければ作成し、ログイン成功時にローカルストレージへセッションIDをキーとしてログイン情報を保持させるようにすればよい、ということになります。

---

`HTTPContext`が使えないので、`セッションID`は`NewGuid`で代用しています。
以下の処理で上記の1～5の処理が実装出来ました。

```C#:Index.razor.cs
protected override async Task OnAfterRenderAsync(bool firstRender)
{
    //IsPostbackプロパティのような感じ?
    if (firstRender)
    {
        //セッションIDを持っていなければクッキーに埋め込む
        var newSessionID = Guid.NewGuid().ToString();
        var cookie = await CookieService.ReadCookieAsync().ConfigureAwait(false);
        if (cookie == null)
        {
            await CookieService.WriteCookieAsync(SessinID, newSessionID).ConfigureAwait(false);
        }
        else
        {
            var sessionID = cookie.FirstOrDefault(x => x.Key == SessinID);
            if (sessionID.Key == null)
            {
                //セッションIDを持っていなければクッキーに埋め込む
                await CookieService.WriteCookieAsync(SessinID, newSessionID).ConfigureAwait(false);
            }
            else
            {
                //セッションIDを持っておりローカルストレージにもログイン情報がある場合
                var loginData = await LocalStorage.GetItemAsync<LoginData>(sessionID.Value).ConfigureAwait(false);
                if (loginData != null)
                {
                    Navigation.NavigateTo("Chat", false);
                }
            }
        }
    }
}
```

また、`Validation`のチェックにかからず正しいユーザーでログインできた場合にはローカルストレージへログイン情報を追加してあげれば完了です。この間にクッキーが削除されている可能性があるので、念の為にクッキーへの書き込み処理を追加しています。

```C#:Index.razor.cs
public async Task OnValidSubmit(EditContext context)
{
    Console.WriteLine($"OnValidSubmit()");

    var errorMessage = await SignInUserAsync().ConfigureAwait(false);
    if (string.IsNullOrEmpty(errorMessage))
    {
        var cookie = await CookieService.ReadCookieAsync();
        var sessionID = cookie.FirstOrDefault(x => x.Key == SessinID);
        if (sessionID.Key == null)
        {
            //セッションIDを持っていなければクッキーに埋め込む
            var newSessionID = Guid.NewGuid().ToString();
            await CookieService.WriteCookieAsync(SessinID, newSessionID).ConfigureAwait(false);
            sessionID = cookie.Single(x => x.Key == SessinID);
        }
        await LocalStorage.SetItemAsync(sessionID.Value, LoginData).ConfigureAwait(false);

        Navigation.NavigateTo("Chat", false);
    }
    else
    {
        LoginErrorMessage = errorMessage;
    }
}
```

###実行してみる
ログイン画面がちらっと見えてしまいますが、すぐにチャットページへルーティングされることが確認できます。
![Counter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172223/8656894f-317f-94b4-7aed-e9673fbc9e0f.gif)

ログイン画面が見えてしまうのは恐らく、`OnAfterRenderAsyncメソッド`でルーティングしているせいだと考えています。しかし、`OnBeforeRenderメソッド`はありません。

ただプルリクエストがあったので、今後は使えるようになることでしょう。
[OnBeforeRender #1716](https://github.com/dotnet/blazor/pull/1716/files)

###まとめ
これまでの記事では`フルC#`でしたが、クッキーを扱う為に`JavaScript`を使うハメになってしまいました。恐らく今後は、こういった`C#`以外の部分はライブラリが出てくると思うのでそれに期待したいと思います。

次回は、認証をしなくてもチャットページへルーティング出来てしまう問題を解決したいと思います。


**参考にさせていただいたページ**

- [セキュリティ対策はばっちり？ セッションとCookieの違いとPHPでの使い方をご紹介！](https://amg-solution.jp/blog/7061)
- [ASP.NET Core Blazor 依存関係の挿入](https://docs.microsoft.com/ja-jp/aspnet/core/blazor/fundamentals/dependency-injection?view=aspnetcore-3.1#use-di-in-services)
- [BlazorでSPAするぞ！(7) - DI(Dependency Injection) -正式版対応済](https://ryuichi111std.hatenablog.com/entry/2019/05/02/213259)
- [InvalidOperationException: Cannot consume scoped service 'Microsoft.JSInterop.IJSRuntime' from singleton '…IAuthentication' in Blazor
](https://stackoverflow.com/questions/60574103/invalidoperationexception-cannot-consume-scoped-service-microsoft-jsinterop-ij)
- [BlazorアプリケーションでC#のコードからJavaScriptを呼び出す](https://www.ipentec.com/document/csharp-blazor-application-call-javascript-from-csharp-code)
- [ASP.NET Core Blazor で .NET メソッドから JavaScript 関数を呼び出す](https://docs.microsoft.com/ja-jp/aspnet/core/blazor/call-javascript-from-dotnet?view=aspnetcore-3.1&viewFallbackFrom=aspnetcore-3.0)
- [cookieをjavascriptで設定、取得、削除する簡単な方法](https://yukimonkey.com/data/cookie/)
- [ASP.NET Core Blazor 状態管理](https://docs.microsoft.com/ja-jp/aspnet/core/blazor/state-management?view=aspnetcore-3.1&viewFallbackFrom=aspnetcore-3.0)
- [「Cookie」と「セッション」と「セッションCookie」の違い](https://ips.nekotype.com/2441/)