WebSurfer's Home

Filter by APML

認証チケットの期限切れをユーザーに通知

by WebSurfer 11. May 2024 16:15

ASP.NET Core Identity を使ったユーザー認証システムで認証チケットの有効期限が切れたとき、下の画像のようにユーザーにその旨通知する方法を書きます。

認証チケット有効期限切れの通知

先の記事「ASP.NET Core Identity タイムアウト判定」で書いたこととほぼ同じで、違うのは先の記事ではミドルウェアを使っていたのを Login ページで行うようにし、ターゲットフレームワークを .NET Core 3.1 から .NET 8.0 に変更した点です。

Visual Studio 202 のテンプレートを使って ASP.NET Core Web アプリのプロジェクトを作る際、認証を「個別のユーザーアカウント」に設定すると ASP.NET Core Identity を利用したクッキーベースのユーザー認証が実装されます。

ユーザーがログインに成功すると、サーバーからは応答ヘッダの Set-Cookie に認証チケットを入れて認証クッキーとしてクライアント(ブラウザ)に送信します。

その後は、ブラウザは要求の都度サーバーに認証クッキーを送信するので認証状態が継続されるという仕組みになっています。

認証チケットには有効期限があります (デフォルトで 5 分、SlidingExpiration で延長あり)。一旦認証を受けたユーザーがログオフせずブラウザを立ち上げたまま長時間席を外すなどして、タイムアウトに設定した時間を超えてアクセスしなかった場合を考えてください。

タイムアウトとなった後でユーザーが席に戻ってきて再度どこかのページを要求したとすると、要求したページが匿名アクセスを許可してなければ、ログインページにリダイレクトされます。

その際、ユーザーに認証チケットが期限切れとなっていることを知らせるためには、どのようにできるかということを書きます。

一旦認証を受けたが、認証チケットが期限切れになっているというのは、Web サーバー側では以下の条件で判定できるはずです。

  1. 要求 HTTP ヘッダーに認証クッキーが含まれる。
  2. 認証クッキーの中の認証チケットが期限切れ。

一旦認証クッキーの発行を受ければ、ブラウザを閉じない限り要求の都度サーバーにクッキーを送り続けます。(下の「注 1」に書きましたが、[Remember me?]チェックボックスにチェックを入れて認証を受けた場合は話が違ってくるので注意してください)

Web サーバーが認証クッキーを取得できれば、それを復号して認証チケットを取得し、チケットの中の有効期限の日時の情報を取得できます。それで上記の 2 つの条件を確認できます。

ログインページで条件を確認して、認証チケットが期限切れになっていた場合はこの記事の一番上の画像のようにメッセージを表示してみました。

以下がログインページのコードです。スキャフォールディング機能を利用して自動生成した Login.cshtml.cs, Login.cshtml のコードに手を加えています。(スキャフォールディング方法の詳細は別の記事「ASP.NET Core MVC プロジェクトに Identity 実装」を見てください)

Login.cshtml.cs

コメントで「2024/5/11 追加」としたコードを自動生成された Login.cshtml.cs に追加しています。

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authentication;
// ・・・中略・・・

namespace MvcNet8App2.Areas.Identity.Pages.Account
{
    public class LoginModel : PageModel
    {
        private readonly SignInManager<ApplicationUser> _signInManager;
        private readonly ILogger<LoginModel> _logger;

        // 2024/5/11 追加(下の「注 2」参照)
        private readonly CookieAuthenticationOptions _options;

        public LoginModel(SignInManager<ApplicationUser> signInManager, 
                          ILogger<LoginModel> logger,

                          // 2024/5/11 追加(下の「注 2」参照)
                          IOptions<CookieAuthenticationOptions> options)
        {
            _signInManager = signInManager;
            _logger = logger;

            // 2024/5/11 追加(下の「注 2」参照)
            _options = options.Value;
        }

        // ・・・中略・・・

        public async Task OnGetAsync(string returnUrl = null)
        {
            // 2024/5/11 追加・・・
            // .AspNetCore.Identity.Application はデフォルトのクッキー名
            string cookie = Request.Cookies[".AspNetCore.Identity.Application"];
            if (!string.IsNullOrEmpty(cookie))
            {
                IDataProtectionProvider provider = 
                                      _options.DataProtectionProvider;

                // 下の「注 3」参照
                IDataProtector protector = provider.CreateProtector(
                    // CookieAuthenticationMiddleware のフルネーム
                    "Microsoft.AspNetCore.Authentication.Cookies." +
                    "CookieAuthenticationMiddleware",
                    // クッキー名 .AspNetCore.Identity.Application から
                    // .AspNetCore. を除去した文字列
                    "Identity.Application",
                    // .NET Framework 版は "v1"、Core 版は "v2"
                    "v2");

                // 認証クッキーから暗号化された認証チケットを復号
                TicketDataFormat format = new TicketDataFormat(protector);

                // 下の「注 3, 4」参照
                AuthenticationTicket authTicket = format.Unprotect(cookie);

                // ユーザー名を取得
                ClaimsPrincipal principal = authTicket.Principal;
                IIdentity identity = principal.Identity;
                string userName = identity.Name!;

                // 認証チケットの有効期限の日時を取得
                AuthenticationProperties property = authTicket.Properties;
                DateTimeOffset? expiersUtc = property.ExpiresUtc;

                // expiresUtc と現在の時刻を比較して期限切れか否かを判定
                if (expiersUtc.Value < DateTimeOffset.UtcNow)

                {
                    ViewData["AuthTicket"] = $"{userName} さん、"+
                      $"認証チケットの有効期限 {expiersUtc.Value} が切れています。";
                }
            }
            // ・・・追加はここまで

            // ・・・中略・・・
        }

        public async Task<IActionResult> OnPostAsync(string returnUrl = null)
        {
            
            // ・・・中略・・・

        }
    }
}

Login.cshtml

認証ケットが期限切れの場合は cshtml から ViewData で情報をもらって期限切れのメッセージを表示するための一行を追加したのみです。

@page
@model LoginModel

@{
    ViewData["Title"] = "Log in";
}

<h1>@ViewData["Title"]</h1>

@* 2024/5/11 以下の一行を追加 *@
<p><font color=red>@ViewData["AuthTicket"]</font></p>

<div class="row">
    <div class="col-md-4">
        <section>

・・・中略・・・

</div>

@section Scripts {
    <partial name="_ValidationScriptsPartial" />
}

注 1 : デフォルトのログインページには[Remember me?]チェックボックスがあって、これにチェックを入れて認証を受けると Set-Cookie ヘッダに expires 属性が追加され認証クッキーが HDD / SSD に保存されます。expires 属性に指定される期限は認証チケットの有効期限と同じになります。期限を過ぎるとブラウザ側で認証クッキーを削除してしまいますので、条件 1 の「要求 HTTP ヘッダーに認証クッキーが含まれる」ことの判定はできなくなります。

注 2 : DI 機能により CookieAuthenticationOptions オブジェクトを取得します。それをベースに復号に必要な TicketDataFormat オブジェクトを取得し、認証クッキーから認証チケットを復号しています。

Visual Studio で「個別のユーザーアカウント」を認証に選んで作成したプロジェクトまたはスキャフォールディング機能を使って ASP.NET Core Identity を追加したプロジェクトでは以下のコードが Program.cs 含まれるはずです。その場合、コンストラクタの引数に IOptions<CookieAuthenticationOptions> options を含めるだけで DI 機能が働きます。

builder.Services.AddDefaultIdentity<ApplicationUser>(options => 
    options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>()

注 3 : IDataProtectionProvider.CreateProtector メソッドの引数ですが、これには暗号化するときに使用した値 (��号化を特定の目的にロックするためのものらしい)と同じものを設定する必要があります。それが CookieAuthenticationMiddleware の名前、認証クッキー名、ASP.NET Identity のバージョンになるようです。

暗号化するときに使用した値と、上の CreateProtector メソッドの引数に指定したものが違うと復号に失敗し、上のコードで authTicket が null になり、次の行の authTicket.Principal で NullReferenceException がスローされます。

注 4 : 別の記事「ASP.NET Core でのデータ保護キーの管理」で書きましたがデータ保護キーがリサイクルで失われることがあります。その場合は復号に失敗し、上のコードで authTicket が null になり、次の行の authTicket.Principal で NullReferenceException がスローされます。

リサイクルで失われるようなことは無くても、データ保護キーの有効期限はデフォルトで 90 日だそうですので、有効期限を過ぎると復号に失敗すると思われます (未検証・未確認です)。

Tags: , , , ,

Authentication

Razor クラスライブラリ

by WebSurfer 20. March 2024 15:43

Razor Class Library (RCL) に ASP.NET Core MVC の Controller と View を実装し、それを ASP.NET Core MVC アプリから利用する例を書きます。

Razor クラスライブラリの利用

話の発端は Microsoft Q&A のスレッド「.NET MVCで参照登録したDLLのビューを使用する方法」でクラスライブラリ中のビューが見つからないという話です。それに対処する方法を検討した時の話を備忘録として残しておくことにしました。

ASP.NET Core MVC の cshtml ファイルは、Microsoft のドキュメント「ASP.NET Core での Razor ファイルのコンパイル」によるとデフォルトでビルド時及びデプロイ時に dll にコンパイルされるそうなので、.NET Framework 版の MVC のようなランタイムコンパイルだからダメということはなさそうです。

ただし、アクションがビューを返すときにビューの検出と呼ばれるプロセスが行われるそうで、Microsoft のドキュメント「ビューの検出ルール」に書いてあるように、既定のフォルダに当該ビューが存在しなければなりません。

Microsoft Q&A の話は、独立したクラスライブラリに MVC の Controller と View を実装した結果、MVC アプリがビューを見つけることができず、InvalidOperationException: The view 'Index' was not found いう例外がスローされたということです。

クラスライブラリではなくて、ASP.NET Core MVC 本体のプロジェクトに Controller, Model, View を実装すればそういう悩みは解消できますが、どうしても独立したライブラリを使わなければならない事情があるなら、Razor Class Library として作るのが良さそうです。

Razor Class Library は Razor Pages だけでなく MVC もサポートしており、設定によって Controller, View, Model を実装して、MVC アプリからそれらを呼び出すことができるそうです。

ということで、Microsoft のドキュメント「ASP.NET Core の Razor クラス ライブラリ プロジェクトを使用した再利用可能 UI の作成」とネットで見つけた記事「Working with Razor Class Libraries in ASP.NET Core」を参考にして作ってみました。

ソリューションの構成

上の画像の RazorClassLib が RCL プロジェクトで、RazorClassLibraryHost が MVC アプリのプロジェクトです。両方とも Visual Studio 2022 のテンプレートで作って、前者には Controllers, Models, Views フォルダを追加し、それらの中に Controller, Model, View クラスを実装しています。後者はテンプレートで作ったまま何も手を加えてません。

MVC アプリの実行結果がこの記事の一番上の画像で、MVC アプリから RCL の Controller / View を呼び出して結果を表示したものです。ビューが見つからないという問題は起こりません。

何も難しいことは無かったのですが、Visual Studio で RCL プロジェクトを作成する際に注意すべき点があって、それは[サポート ページとビュー]にチェックを入れることです。

[サポート ページとビュー]にチェック

上に紹介した Microsoft のドキュメントに "Select Support pages and views if you need to support views. By default, only Razor Pages are supported." と書いてありますが、デフォルト([サポート ページとビュー]にチェック無し)では Razor Pages も MVC もサポートされません。その下に書いてある "The Razor class library (RCL) template defaults to Razor component development by default. The Support pages and views option supports pages and views." が正しいです。

Microsoft のドキュメント「クラス ライブラリで ASP.NET Core API を使用する」の「MVC 拡張機能を含める」のセクションの説明によると以下の設定が必要とのことです。

  1. Microsoft.NET.Sdk.Razor SDK を使用する。
  2. true に設定された AddRazorSupportForMvc MSBuild プロパティ。
  3. 共有フレームワークの <FrameworkReference> 要素。

Visual Studio 2022 の RCL テンプレートで[サポート ページとビュー]にチェックを入れて作成したプロジェクトは上の要件を満たしていて、生成されるプロジェクトファイルの内容は以下のようになります。

<Project Sdk="Microsoft.NET.Sdk.Razor">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <AddRazorSupportForMvc>true</AddRazorSupportForMvc>
  </PropertyGroup>

  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>
  
</Project>

その他、気が付いた点を以下に書いておきます。

MVC アプリから RCL に参照設定がされていれば、RCL の Controller は、MVC アプリのプロジェクトの Program.cs に含まれる builder.Services.AddControllersWithViews(); で DI コンテナに登録されるようです。なので、参照設定以外は何もしなくても、要求に応じて RCL の Controller が呼び出され応答が返ってきます。

MVC アプリから RCL の Razor Pages を呼び出す場合は、Program.cs に builder.Services.AddRazorPages(); と app.MapRazorPages(); を追加する必要があります。追加しないと見つからないというエラーになります。

MVC アプリに実装されている _Layout.cshtml が自動的に使われます。なので、MVC アプリの wwwroot に実装されている Bootstrap などの CSS や jQuery などの JavaScript も有効になります。

Tags: , , , ,

CORE

参照型と ASP.NET Core の検証

by WebSurfer 23. February 2024 17:49

.NET 6.0 以降の ASP.NET Core Web アプリで、コンテキストクラスとエンティティクラスを使って SQL Server などのデータベースのテーブルの CRUD 操作を行う際、エンティティクラス内の参照型のプロパティを null 許容にしておかないと検証に通らず、Create や Edit 操作に失敗するという話を書きます。特に下の画像のようなナビゲーションプロパティが要注意です。

検証結果が NG

また同じ問題にハマって時間を無駄にしないよう、どういうことかを書いておくことにしました。

先の記事「null 許容参照型と EF Core Code First」で書きましたように、Visual Studio 2022 のテンプレートを使って、ターゲットフレームワークを .NET 6.0 以降の設定にしてプロジェクトを作ると、デフォルトで「Null 許容」オプションが有効になり、Code First の場合は生成されるデータベースのフィールドの NULL 可/不可が、リバースエンジニアリングでデータベースからエンティティクラスを生成する場合はプロパティの型の null 許容/非許容が影響を受けます。

エンティティクラスをビューモデルに使ってブラウザからのデータを MVC アプリのアクションメソッドで受け取る場合、null 許容参照型に注意が必要です。特にナビゲーションプロパティの型による問題は気が付きにくいと思いました。

具体的にどういうことかを下の画像の SQL Server の Blogging データベースの dbo.Blogs, dbo.Posts テーブルを例に使って説明します。

SQL Server データベース

上の dbo.Blogs, dbo.Posts テーブルからリバースエンジニアリングでコンテキストクラスとエンティティクラスを生成します。ターゲットフレームワークは .NET 8.0 で「Null 許容」オプションはプロジェクト全体で有効に設定された状態です。パッケージマネージャコンソールから発行したコマンドを参考に下に載せておきます。

Scaffold-DbContext -Connection "接続文字列" -Provider Microsoft.EntityFrameworkCore.SqlServer -ContextDir Data -OutputDir Models -Tables Posts, Blogs -DataAnnotations

その結果、以下のエンティティクラスが生成されます。(同時にコンテキストクラスも生成されますが省略)

Blog クラス

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace RazorPages1.Models;

public partial class Blog
{
    [Key]
    public int BlogId { get; set; }

    public string Name { get; set; } = null!;

    [InverseProperty("Blog")]
    public virtual ICollection<Post> Posts { get; set; } = 
        new List<Post>();
}

Post クラス

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

namespace RazorPages1.Models;

[Index("BlogId", Name = "IX_Posts_BlogId")]
public partial class Post
{
    [Key]
    public int PostId { get; set; }

    public string Title { get; set; } = null!;

    public string? Content { get; set; }

    public int BlogId { get; set; }
    
    [ForeignKey("BlogId")]
    [InverseProperty("Posts")]
    public virtual Blog Blog { get; set; } = null!;   
}

SQL Server の dbo.Blog テーブルの外部キーフィールド BlogId が NULL 不可になっているため、Blog クラスの Posts ナビゲーションプロパティと、Post クラスの Blog ナビゲーションプロパティの型は Null 許容になりません。

上の Post クラスをベースに、Visual Studio 2022 のスキャフォールディング機能を使って dbo.Posts テーブルの CRUD を行う ASP.NET Core MVC アプリのコントローラーとビューを生成し、アプリを実行してレコードの Create 操作をしようとしたところ ModelState.IsValid が false(検証結果 NG)となって失敗したというのがこの記事の一番上の画像です。Create 操作だけでなく、Edit 操作でも同様に検証 NG で失敗します。

検証 NG となった原因は、Post クラスで null 非許容のナビゲーションプロパティ Bolg に、モデルバインディングの際に null が代入されたからです。

なぜ null が代入されるかですが、下のスキャフォールディングで自動生成された Create 画面を見てください。ブラウザから送信されてくるのは Title, Content, BlogId だけということに注目してください。

Create 画面

一方 Create アクションメソッドの引数は、この記事の一番上の画像の通り Post クラスになっています。ブラウザからデータが送信されてこない PostId, Blog プロパティにはモデルバインディングの際その型のデフォルト値(参照型は null)が代入されます。一番上のデバッグ画面のローカル変数の値を見てください。

Blog ナビゲーションプロパティの型は null 非許容なので、ModelState.IsValid が false になり、Create, Edit に失敗します。

解決策は、生成されたコードに手を加えて Null 許容にすることです。

// 以下のように null 許容にすれば検証は通る
public virtual Blog? Blog { get; set; }

ちなみに、PostId もブラウザからは送信されてきませんのでモデルバインディングでデフォルト値が代入されますが、int 型のデフォルト値は null ではなくて 0 (ゼロ) なので問題は出ません。一番上の画像のローカル変数の青枠を見てください。

また、Title も null 非許容ですが、こちらは検証結果のエラーメッセージがブラウザの画面上の当該テキストボックスの下に表示されるので、すぐ気が付きます。Blog の方はエラーメッセージは出ませんので気づき難いです。

Tags: , , ,

CORE

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。ブログ2はそれ以外の日々の出来事などのトピックスになっています。

Calendar

<<  December 2025  >>
MoTuWeThFrSaSu
24252627282930
1234567
891011121314
15161718192021
22232425262728
2930311234

View posts in large calendar