前回
【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その1~
【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その2~
【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ~その3~
の続きです。
前回は、Cognito
を使用して登録されているユーザーでないとログインできないようにしました。
これだとまだ、とある問題を抱えていますが、その前に今回はログイン情報の保持をさせるようにしたいと思います。
ログイン情報の保持
多くのサービスは以下の様な処理でログイン情報を保持しているかと思います。
今回はこの方式に則って、機能追加をしていこうと思います。
出典:セキュリティ対策はばっちり? セッションとCookieの違いとPHPでの使い方をご紹介!
Blazorによるクッキーへの読み書き
結構調べてはみたんですが、今の所クッキーを扱う情報について見当たりませんでした。
BlazorではHTTPContextは扱えない(?)
.NET MVC Core
などではHTTPContext
を通すことによりセッションやクッキーを扱う事が出来ましたが、Controllerクラス
からでないと扱う事が出来ません。それはBlazor
でも同じなようですが、今のソリューションにControllerクラス
を追加するというのはいささか変(というか、それってBlazor
にとってどういう意味があるの?)と思ったので別の方法を考えてみました。
JavaScriptでクッキーを扱う
別の方法としては、JavaScript
でクッキーを扱うしかないと思いました。Blazor
のよさを損なうような感じがしますがここはしょうがない。実装してみましょう。
呼び出したいJSの処理を追加する
_Host.cshtmlファイル
にWriteCookieメソッド
・ReadCookieメソッド
を追加します。
<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
でどのページでも使いまわせるようにしましょう、という粋な計らいです。
コードは以下の通りです。
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
で追加してあげるだけでおkです。
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を使用する
なのでこのCookieServiceクラス
ではコンストラクタインジェクションを行ってIJSRuntimeクラス
への注入をしています。仮に、プロパティでIJSRuntimeクラス
を宣言しInject属性
を付けたとしてもnull
のままで値は何も入ってきません。
②サービスクラスでデフォルトサービスをDIさせたい場合はデフォルトサービスの有効期間に合わせてサービスを追加する
ちょっと何を言っているか分からない人が多くなってきてそうなので丁寧めに説明します。
サービスクラスというのは、今回作ろうとしているCookieServiceクラス
のことです。
デフォルトサービスというのは、以下の3つのサービスの事を指します。最初から使えるサービス達ですね。
今回、私はサーバーサイドでソリューションを作成しています。
また、デフォルトサービスであるIJSRuntimeクラス
を使用したいと考えています。
上記の画像を見てみればわかりますが、サーバーサイドではIJSRuntimeクラス
の有効期間は「スコープ」となっています。
なので以下の様に、CookieServiceクラス
を「スコープ」で追加しています。
services.AddScoped<CookieService>(); //★この行を追加★
ここを合わせる必要があると分かっていなかったので最初はシングルトンで追加していました。そうすると以下のようなエラーが出力されました。
InvalidOperationException: Cannot consume scoped service 'Microsoft.JSInterop.IJSRuntime' from singleton 'LoginTest.Service.CookieService'.
(InvalidOperationException:シングルトン「LoginTest.Service.CookieService」からスコープサービス「Microsoft.JSInterop.IJSRuntime」を使用できません。)
サービスの有効期間についての説明はいかの通りです。スコープはサーバーサイドにしかない有効期間なのですね。
ログイン情報を使いまわす
クッキーやローカルストレージを利用して、ログイン情報を保持します。また。ログイン情報が保存されているのであれば自動的にチャットページへルーティングしてあげましょう。
まずはNuGet
からBlazored.LocalStorage
をインストールします。
このライブラリを使用することにより、非常に簡単にローカルストレージを使用することができます。ローカルストレージであればブラウザを閉じたり別タブでも保存している情報が引き継げるので今回はローカルストレージを使用します。
出典:JavaScriptのsessionStorageの使い方を現役エンジニアが解説【初心者向け】
ユーザーがページに訪れた際に行うべき処理は次の通りです。
- ログインページへアクセス
- ユーザーのクッキーに保存されているセッションIDを取得
- クッキーのセッションIDと一致するローカルストレージに保持されているセッションIDを見つける
- ローカルストレージ内に保持している前回ログイン情報を取得
- チャットページへルーティングする
クッキーにセッションIDが保存されていなければ作成し、ログイン成功時にローカルストレージへセッションIDをキーとしてログイン情報を保持させるようにすればよい、ということになります。
HTTPContext
が使えないので、セッションID
はNewGuid
で代用しています。
以下の処理で上記の1~5の処理が実装出来ました。
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
のチェックにかからず正しいユーザーでログインできた場合にはローカルストレージへログイン情報を追加してあげれば完了です。この間にクッキーが削除されている可能性があるので、念の為にクッキーへの書き込み処理を追加しています。
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;
}
}
実行してみる
ログイン画面がちらっと見えてしまいますが、すぐにチャットページへルーティングされることが確認できます。
ログイン画面が見えてしまうのは恐らく、OnAfterRenderAsyncメソッド
でルーティングしているせいだと考えています。しかし、OnBeforeRenderメソッド
はありません。
ただプルリクエストがあったので、今後は使えるようになることでしょう。
OnBeforeRender #1716
まとめ
これまでの記事ではフルC#
でしたが、クッキーを扱う為にJavaScript
を使うハメになってしまいました。恐らく今後は、こういったC#
以外の部分はライブラリが出てくると思うのでそれに期待したいと思います。
次回は、認証をしなくてもチャットページへルーティング出来てしまう問題を解決したいと思います。
参考にさせていただいたページ
- セキュリティ対策はばっちり? セッションとCookieの違いとPHPでの使い方をご紹介!
- ASP.NET Core Blazor 依存関係の挿入
- BlazorでSPAするぞ!(7) - DI(Dependency Injection) -正式版対応済
- InvalidOperationException: Cannot consume scoped service 'Microsoft.JSInterop.IJSRuntime' from singleton '…IAuthentication' in Blazor
- BlazorアプリケーションでC#のコードからJavaScriptを呼び出す
- ASP.NET Core Blazor で .NET メソッドから JavaScript 関数を呼び出す
- cookieをjavascriptで設定、取得、削除する簡単な方法
- ASP.NET Core Blazor 状態管理
- 「Cookie」と「セッション」と「セッションCookie」の違い