WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

ASP.NET Core アプリに 2 要素認証を実装

by WebSurfer 6. November 2021 12:32

Visual Studio 2019 のテンプレートで作成した ASP.NET Core 3.1 以降のアプリには TOTP ベースの認証アプリを用いて 2 要素認証を行うコードが実装されています。なので、開発の際プログラマは何も実装しなくても、Google Authenticator App などの認証アプリを用いた 2 要素認証を有効にできます。

EnableAuthenticator 画面

実は、最初、自分は Microsoft のドキュメント「ASP.NET Core での多要素認証」にいろいろ書いてあるのを見て惑わされて、一体何をどのように実装すればいいのか分からなかったです。(汗)

実際に試してみると、そのドキュメントの「MFA TOTP (時間ベースのワンタイム パスワード アルゴリズム)」セクションの 2 要素認証のコードは実装済みで、何も手を加える必要はなかったです。

唯一、認証アプリに秘密キーを共有させる画面(Manage/EnableAuthenticator 画面)には QR コードが表示されないのが難点でしたが、そこは別のドキュメント「ASP.NET Core での TOTP authenticator アプリの QR コード生成を有効にする」に qrcode.js という JavaScript ライブラリを使って表示できるようにする方法が書いてありました。

この記事の一番上の画像が、そのドキュメントに従って QR コードを表示できるようにした Manage/EnableAuthenticator 画面です。そうするだけで、テンプレートで実装済みの 2 要素認証は十分実用になると思います。

という訳で、実際にいろいろやってみましたので、その結果を備忘録として残しておきます。

(1) プロジェクトの作成

Visual Studio 2019 16.11.5 のテンプレートで、認証は「なし」にして .NET 5.0 の ASP.NET Core MVC アプリのプロジェクトを作成しました。

その後、スキャフォールディング機能を利用して ASP.NET Core Identity 関係のソースコードを一式全て実装します。(認証を「個別のユーザー」にすると、認証関係の機能は Razor Class Library (RCL) として提供され、ソースコードはプロジェクトには含まれません。後で必要な部分のみソースコードに差し替えることができますが、いろいろ面倒です)

(2) ユーザーの登録

新規ユーザーとして oz@mail.example.com (以下、oz と書きます) というユーザーを登録します。

ユーザー登録しただけの状態ですと、ASP.NET Core Identity が使う SQL Server データベースの AspNetUsers テーブルの当該ユーザーの TwoFactorEnabled 列は False となっています。

AspNetUsers テーブル

TOTP 用の秘密キー等は AspNetUserTokens テーブルに格納されますが、この時点ではユーザー oz のデータはありません。

(2) TOTP 秘密キーの生成

ログインして Manage/Index 画面に進み、[Two-factor authentication]をクリックして Manage/TwoFactorAuthentication 画面を表示して、そこで [Add authenticator app]をクリックします。

TwoFactorAuthentication 画面

この記事の一番上の画像の Manage/EnableAuthenticator 画面に遷移しますので、そこで認証アプリに秘密キーを渡すことができます。

ユーザー oz の TOTP 用の秘密キーが生成され、SQL Server データベースの AspNetUserTokens テーブルに格納されます。

AspNetUserTokens テーブル

ちなみに、もしここで認証アプリには秘密キーは渡さずログオフした場合、次回ログインして Manage/TwoFactorAuthentication 画面を表示すると上の Manage/TwoFactorAuthentication 画面とは違って以下のようになります。

TwoFactorAuthentication 画面

[Setup authnticator app]をクリックすると、この記事の一番上の画像の Manage/EnableAuthenticator 画面に遷移します。

[Reset authentication app] をクリックすると Manage/ResetAuthenticator 画面に遷移します。

ResetAuthenticator 画面

上の画面で[Reset authenticator key]をクリックすると、秘密キーが変更されてから、この記事の一番上の画像の Manage/EnableAuthenticator 画面に遷移します。

(3) TOTP 秘密キーの共有

この記事の一番上の画像の Manage/EnableAuthenticator 画面に表示された QR コードを認証アプリでスキャンすると 6 桁の数字のパスワードをが認証アプリに表示されますので、それを[Verification Code]欄に入力し[verify]ボタンをクリックすると以下の Manage/ShowRecoveryCodes 画面に遷移します。

ShowRecoveryCodes 画面

認証アプリをインストールしたデバイスを紛失するとアクセスできなくなるので、それに備えてリカバリーコードは記録しておいた方が良いとのことです。リカバリーコードは秘密キーと共に AspNetUserTokens テーブルに保存されます。

AspNetUserTokens テーブル

以上の操作により、ASP.NET Core アプリと認証アプリで秘密キーが共有され、AspNetUsers テーブルの TwoFactorEnabled は True に設定されて、認証アプリを利用しての 2 要素認証が可能になります。

(4) 2 要素認証でのログイン

一旦ログアウトしてから、まずログイン画面でのメールとパスワードによるログイン操作を行います。成功すると以下の Account/LoginWith2fa 画面に遷移しますので、認証アプリを起動してパスワードを取得し、[Authenticator code]欄に入力して[Log in]ボタンをクリックすれば 2 要素認証機能が働いでログインできます。

LoginWith2fa 画面

(5) その他

2 要素認証が有効になった後 Manage/TwoFactorAuthentication 画面を表示すると以下のようになります。

TwoFactorAuthentication 画面

そこで[Disable 2FA]ボタンをクリックすると Manage/Disable2fa 画面に遷移します。

Disable2fa 画面

上の画面で[Disable2fa]ボタンをクリックすると AspNetUsers テーブルの当該ユーザーの TwoFactorEnabled 列は False となり、ログイン画面でメールとパスワードでログイン操作を行うだけでログインできるようになります(2 要素認証は無効になります)。

再度 2 要素認証を有効にするには、ログイン画面でメールとパスワードでログイン操作を行ってログインしてから Manage/TwoFactorAuthentication 画面に進み、[Setup authenticator app]ボタンをクリックして Manage/EnableAuthenticator 画面を表示し、上のステップ (3) と同じ操作を行います。

[Reset recovery codes]ボタンをクリックすると Manage/GenerateRecoveryCodes 画面に遷移します。

GenerateRecoveryCodes 画面

そこで[Generate Recovery Codes]ボタンをクリックすると、上のステップ (3) にあるのと同様な Manage/ShowRecoveryCodes 画面に遷移し、再発行されたリカバリーコードが表示されます。同時に AspNetUserTokens テーブルの既存のリカバリーコードは画面に表示されたものに書き換えられます。


なお、MVC5 ではテンプレートに標準で実装されていた SMS を用いた 2 要素認証は非推奨だそうです。上に紹介した記事には "SMS を使用する方法は推奨されなくなりました。 この種の実装には、既知の攻撃ベクトルが多すぎます" と書いてあります。

Core 1.1 用には「SMS を使用した 2 要素認証 (ASP.NET Core)」というチュートリアルがありますが、.NET Core 3.1 とか .NET 5.0 の新しいバージョンのテンプレートで作ったプロジェクトにはそのチュートリアルのコードは実装されていません。

Tags: , , , , , ,

CORE

MySQL で Contoso University チュートリアル (CORE)

by WebSurfer 19. October 2021 12:22

データベースに MySQL を利用して、ASP.NET Core MVC アプリを Micorsoft のチュートリアル「ASP.NET Core MVC と EF Core - チュートリアル シリーズ」に従って作成してみました。その際に気になったこと、憶えておいた方が良さそうなことを備忘録として残しておきます。

Contoso University アプリ

チュートリアルはデータベースに SQL Server (LocalDB) を使うことを前提に書かれています。それを MySQL に代えて、チュートリアルの「1. 開始するには」から「8. コンカレンシーの競合の処理」までの手順に従って実装しました。

この記事で使った MySQL サーバーは Windows 10 にインストールしたバージョン 8.0.19 です。詳しくは先の記事「MySQL をインストールしました(その 3)」を見てください。

以下に「1. 開始するには」から「8. コンカレンシーの競合の処理」の各手順において、気になったこと、憶えておいた方が良さそうなことを書いておきます。

一番気になっていたのは「8. コンカレンシーの競合の処理」の手順の楽観的同時実行制御の実装でしたが、モデルのプロパティの定義だけ変更すれば MySQL でも可能でした。その結果が上の画像です。

(1) 開始するには

この記事では、チュートリアルに書いてあるようなプロジェクトの新規作成はせず、先の記事「MySQL で Movie チュートリアル (CORE)」で作成した既存のプロジェクトをベースに使いました。

まず Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore を NuGet からインストールします。その他の必要な NuGet パッケージはベースに使った既存のプロジェクトにインストール済です。結果は以下のようになります。

NuGet パッケージ

Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore の役割は "ASP.NET Core middleware for Entity Framework Core error pages. Use this middleware to detect and diagnose errors with Entity Framework Core migrations." だそうです。チュートリアルの「データベース例外フィルターを追加する」と関係があるようです (未確認ですが)。

Strtup.cs ファイルにある Startup クラスの ConfigureServices メソッドに SchoolContext を登録するコードを追加します。以下の通りです。

SchoolContext を登録

チュートリアルの手順「SchoolContext を登録する」に書いてあるコードは SQL Server 用で、これを MySQL 用に変更する必要がありますので注意してください。

次に appsettings.json ファイルに接続文字列を設定します。チュートリアルの例は LocalDB 用ですので MySQL 用に変更します。以下のような感じです (あくまで「感じ」です。MySQL の設定により異なります)。

接続文字列を設定

チュートリアルの「コントローラーとビューを作成する」の手順でスキャフォールディングを行う際、NuGet パッケージに Microsoft.EntityFrameworkCore.SqlServer が必要になるケースがありますので注意してください。エンティティクラスの定義によると思われます。ここ「(1) 開始するには」の手順では不要でしたが、下の「(6) 関連データの読み取り」では必要でした。

アプリを実行すると EF Code First の機能を使って、接続文字列で database=ContosoUniversity1 と指定した通り ContosoUniversity1 という名前のデータベースと、SchoolContext クラスで指定した Course, Enrollment, Student という名前のテーブルが生成されます。

さらにチュートリアルには初期データをシードするためのコードが含まれており、その通り実装すれば、最初にアプリを実行した時にコードに書かれた通り初期データが Course, Enrollment, Student テーブルに登録されます。

(2) 作成、読み取り、更新、削除の操作

チュートリアルある通りに実装してチュートリアル通りの結果が得られます。

(3) 並べ替え、フィルター、ページング、グループ化

チュートリアルある通りに実装してチュートリアル通りの結果が得られます。

(4) 移行

ここは Migration 操作を学ぶことを目的としているようです。

チュートリアルでは dotnet コマンドを使っていますが、Visual Studio を使っているならそのパッケージマネージャーコンソールを使った方が簡単だと思います。

NuGet パッケージ Microsoft.EntityFrameworkCore.Tool が必要ですが、上のステップ (1) の画像の通りインストール済みですので、このチュートリアルの操作にはパッケージマネージャーコンソールを使いました。

Drop-Database, Add-Migration, Update-Database という操作を行いますが、データベースが MySQL だから何か問題が出るということは、少なくともこのチュートリアルの操作ではありませんでした。(この後のセクション「(5) 複合データ モデルの作成」では 2 点問題がありましたが)

ベースに使った既存のプロジェクトには他の既存のコンテキストクラスが含まれています。そういう場合は、Drop-Database, Add-Migration, Update-Database 操作の際に、既存のコンテキストと区別するため、-Context SchoolContext オプションの追加が必要です。

(5) 複合データ モデルの作成

MySQL ではチュートリアルの通りにはできないということがありました。問題があったのは以下の 2 点です。

  1. Column 属性」のセクションで Student クラスの FirstMidName プロパティに付与した [Column("FirstName")] 属性。
  2. Department エンティティを作成する」のセクションで Department クラスの Budget プロパティに付与した [Column(TypeName = "money")] 属性。

いずれも Add-Migration の後の Update-Database コマンドで前者は NotImplementedException、後者は MySqlException がスローされ、MySQL のテーブルの変更・生成に失敗します。

前者は既存の Student テーブルの FirstMidName 列の名前を Migration 操作で FirstName という名前に変更しようというものです。NotImplementedException 例外がスローされるということは MySQL 用の Entity Framework にその実装がされてないということのようです。

後者は MySQL には TypeName = "money" で指定した money 型はないためのエラーです。

上記の操作に失敗で先に進めることができなくなったしまったので、やむを得ずゼロから作り直しました。Drop-Database で ContosoUniversity3 をドロップし、Add-Migration で生成された Migrations フォルダのファイルを削除し、チュートリアルに従ってコードの修正・追加を最後まで終わらせてから(途中で Migration 操作は行わないで)、最後に一気に Add-Migration, Update-Database コマンドでデータベースを生成しました。

問題となった Student クラスの FirstMidName プロパティに付与した [Column("FirstName")] 属性ですが、データベースを生成する最初の時点で付与しておけば指定した通り Student テーブルの当該列の名前は FirstName になります。チュートリアルのように最初に FirstMidName という列名で作って、後で Migration 操作で変更しようとすると、その操作に必要なメソッド等が実装されてないからか、NotImplementedException がスローされます。

Department クラスの Budget プロパティに付与した [Column(TypeName = "money")] 属性は [Column(TypeName = "decimal")] に変更が必要です。

チュートリアルの「移行を追加する」の手順に、Migrations フォルダに生成された ComplexDataModel クラスのコードに手を加えるように書かれていますが、それは先の操作でデータベースに作成済みの既存のレコードとの整合を取るためのもので、ゼロから一気に作るのであれば不要です。

(6) 関連データの読み取り

スキャフォールディング操作の際 NuGet パッケージに Microsoft.EntityFrameworkCore.SqlServer が必要になります。ちなみに「(1) 開始するには」の手順では不要でした。上の「(5) 複合データ モデルの作成」でいろいろ変更・追加したエンティティクラスの定義によると思われます。

そのため、最初のスキャフォールディング操作には失敗しますが、その際 Microsoft.EntityFrameworkCore.SqlServer が自動的に追加されますので、以下のようになっていることを確認し再度スキャフォールディングすれば成功すると思います。

NuGet パッケージの確認

上の画像の 1 番目から 4 番目のパッケージ間でのバージョンの不一致、ランタイムとのバージョンの不一致があるとスキャフォールディングに失敗することがありますので注意してください。

もう一つ、MySQL とは関係ないチュートリアルのミスですが、Courses Taught by Selected Instructor テーブルを表示する View のコードで selectedRow = "success"; となっていますが、selectedRow = "table-success"; にしないと Select しても背景色が変わらないので修正してください。

(7) 関連データの更新

チュートリアルある通りに実装してチュートリアル通りの結果が得られます。

(8) コンカレンシーの競合の処理

チュートリアルの手順では Department テーブルに rowversion (Transact-SQL) 列を追加し、それを使って楽観的同時実行制御を実装するというものです。

MySQL には rowversion はなく、それに代わるものとして timestamp というものがありますが同じではありません。.NET 側では rowversion は byte[] 型、timestamp は DateTime 型になるのですが、そのような違いがあっても Entity Framework による楽観的同時実行制御に使えるかが懸念したところです。

チュートリアルには Department クラスに byte[] 型の RowVersion プロパティを追加して Migration 操作でデータベースの Department テーブルに RowVersion 列を追加するよう書かれています。まずそこを MySQL ように変更する必要があります。

ググって見つけた記事 entity framework 6 mysql rowversion を参考に以下のプロパティ定義を使いました。

[Timestamp]
[ConcurrencyCheck]
[DatabaseGenerated(DatabaseGeneratedOption.Computed)]
public DateTime TimeStamp { get; set; }

timestamp では分解能が不十分という話もあるようですが、11.3.1 DATE、DATETIME、および TIMESTAMP 型によると "DATETIME または TIMESTAMP 値には、マイクロ秒 (6 桁) までの精度で後続の小数秒部分を含めることができます" とのことですので十分ではないかと思います。なので今回は上のプロパティ定義で実装してみました。

マイクロ秒以内で同時実行が行われるケースも考えなければならないとか、どうしても分解能が気になるという場合は Better way to implement a row version with EF Core and MySQL? に紹介されている方法もありそうです。

Add-Migration, Update-Database コマンドで MySQL の Department テーブルには以下のようにそれらしい形で datetime 型の Timestamp 列が追加されます。timestamp 型でないのがちょっと気になりますが。

Department テーブル

特に気になっていたのはスキャフォールディング機能を使って Department テーブルの CRUD 用のコントローラーとビューを生成したとき TimeStamp 列を使って同時実行制御を行う機能が含まれるかということです。

結果は、SQL Server の場合と同様に、同時実行で DbUpdateConcurrencyException 例外がスローされ楽観的同時実行制御ができるようになりました。この記事の一番上の画像がその結果です。

なお、プロパティ名をチュートリアルの RowVersion から TimeStamp に変えましたので、チュートリアルの「Edit メソッドを更新する」の Edit アクションメソッドのコードで RowVersion, rowVersion となっているところは TimeStamp, timeStamp に変更が必要です。型の指定も byte[] となっているところを DateTime に変更する必要があります。

Edit 用の View のコードも同様で、「Edit ビューを更新する」のコードで RowVersion, rowVersion となっているところは TimeStamp, timeStamp に変更が必要です。

Delete については View の <input type="hidden" asp-for="RowVersion" /> の RowVersion を TimeStamp に変更します。


チュートリアルにはこの先に「9. 継承」と「10. 高度なトピック」がありますが、それはまた次の機会にということで・・・

Tags: , , , ,

CORE

SQLite で Movie チュートリアル (CORE)

by WebSurfer 10. October 2021 14:50

データベースに SQLite を利用して、Micorsoft の ASP.NET Core MVC のチュートリアル「ASP.NET Core MVC の概要」および「パート 4、ASP.NET Core MVC アプリにモデルを追加する」に従ってアプリを作る方法を書きます。

Movie アプリ

先の記事「MySQL で Movie チュートリアル (CORE)」ではデータベースに MySQL を使った例を書きましたが、この記事はその SQLite 版です。

(1) プロジェクトの作成

チュートリアル「ASP.NET Core MVC の概要」の通り、Visual Studio 2019 のテンプレートを利用して対象のフレームワークは .NET 5.0認証「なし」の ASP.NET Core MVC アプリを作成します。

対象のフレームワークは .NET Core 3.1 でも良いですが、そうした場合は NuGet パッケージのバージョンの選び方に注意してください。間違うとスキャフォールディングでエラーになると思います。

また、認証を「個別のアカウント」にすると SQL Server を利用した Entity Framework 関係のパッケージがインストールされ話がややこしくなりますので、まずは認証は「なし」でやってみることをお勧めします。

(2) モデルの定義とスキャフォールディングの実行

チュートリアル「パート 4、ASP.NET Core MVC アプリにモデルを追加する」に従って、モデル(エンティティ)クラスの定義をプロジェクトに既存の Models フォルダに追加します。

using System;
using System.ComponentModel.DataAnnotations;

namespace SQLiteMovie.Models
{
    public class Movie
    {
        public int Id { get; set; }
        public string Title { get; set; }

        [DataType(DataType.Date)]
        public DateTime ReleaseDate { get; set; }
        public string Genre { get; set; }
        public decimal Price { get; set; }
    }
}

次に、Microsoft.EntityFrameworkCore.Design を NuGet からインストールします。この記事では、この記事を書いた時点での最新版 5.0.10 を使いました。対象のフレームワークを .NET Core 3.1 にした場合は、Microsoft.EntityFrameworkCore.Design のバージョンは 3.1.18 にするのが良さそうです。5.0.10 を使うとスキャフォールディングでエラーになると思います。

チュートリアルの通り Visual Studio でスキャフォールディングを実行すると以下の操作が自動的に行われます。(コードジェネレータ関係のエラーが出る場合がありますが、その際は下の追加 NuGet パッケージがインストールされていることを確認してから再度スキャフォールディングを行うと成功すると思います)

  1. 他に必要な NuGet パッケージ(Microsoft.EntityFrameworkCore.SqlServer, Microsoft.EntityFrameworkCore.Tools, Microsoft.VisualStudio.Web.CodeGeneration.Design)の追加
  2. Data フォルダにコンテキストクラスの作成
  3. Startup.cs ファイルの ConfigureServices メソッドにコンテキストの登録
  4. appsettings.json ファイルへの接続文字列の追加
  5. CRUD 操作に必要な Controller / View 一式の生成

SQL Server の場合は上記でプロジェクトは完成ですが、SQLite を利用する場合は上の 1, 3, 4 に以下の変更を行う必要があります。

(3) NuGet パッケージの変更

スキャフォールディングで自動的に追加された NuGet パッケージ Microsoft.EntityFrameworkCore.SqlServer はこの先の操作には無くても問題ないです。残しておいても問題はありませんが、無くてもアプリは作成できることを確認するため削除してみました。 代わりに Microsoft.EntityFrameworkCore.Sqlite をインストールします。その結果が以下の画像です。

NuGet パッケージ

スキャフォールディングで自動的に追加される Microsoft.EntiryFrameworkCore.Tools のバージョンがランタイムのバージョン(この記事を書いた時点では 5.0.10)より古い場合は Migration 操作の際警告が出るのと思いますので、更新してください。

注意: Microsoft.EntityFrameworkCore.SqlServer はモデルクラスの定義によってはスキャフォールディングで必要になるケースがあるようです。具体的にどのようなケースで必要になるかは調べ切れてませんが、データアノテーション属性が関係しているような感じです。なので、この先モデルの定義を変更して Migration ⇒ スキャフォールディング操作を繰り返すなら、Microsoft.EntityFrameworkCore.SqlServer は残しておいた方が良さそうです。

(4) Startup.cs ファイルの修正

スキャフォールディング操作で Startup.cs ファイルの ConfigureServices メソッドに自動的にコンテキストが登録されますが、それは SQL Server 用なので、以下のように UseSqlServer を UseSqlite に変更します。

UseSqlServer を UseSqlite に変更

コンテキストクラスの登録はコントローラーへの DI に必要です。登録してあれば、フレームワークがクライアントからの要求を受けてコントローラーを初期化する際、コンテキストクラスを初期化してコンストラクタ経由で渡してくれます。

(5) 接続文字列を SQLite 用に変更

スキャフォールディング操作で appsettings.json ファイルに接続文字列が自動生成されますが、それは SQL Server (LocalDB) 用なので SQLite 用に変更します。

接続文字列の変更

上の画像の接続文字列では、プロジェクトのフォルダに Movie.db という名前でデータベースファイルを置くように設定しました。この時点ではデータベースファイルは存在しませんが、 Migration 操作を行うと EF Code First の機能を使って Movie.db というデータベースファイルを新たに生成し、そこに必要なテーブルを生成してくれます。

(6) Add-Migration の実行

ソリューションをリビルドしてから、Visual Studio のパッケージマネージャーコンソールで Add-Migration InitialCreate コマンドを実行します。結果、以下の画像の通り InitialCreate クラスが Migrations フォルダに自動生成されます。

Add-Migration の実行結果

Movie クラスの ReleaseDate プロパティ、Price プロパティの型はそれぞれ DateTime、deciaml ですが、SQLite にはそれらに該当する型がないので type: "TEXT" となっている点に注目してください。

Add-Migration InitialCreate の InitialCreate という名前は任意に指定できます。指定した名前で xxxxx_InitialCreate.cs (xxxxx は作成日時) という名前のファイルが作成され、それに InitialCreate という名前のクラスが定義されます。

(7) Update-Database の実行

Visual Studio のパッケージマネージャーコンソールで Update-Database コマンドを実行します。これにより以下の画像の通り SQLite データベースが生成されます。

Update-Database の実行結果

接続文字列の設定通り Movie.db という名前でデータベースファイルが生成され、InitialCreate クラスの name: "Movie" で指定された名のテーブルが生成されています。

Movie クラスの ReleaseDate プロパティ、Price プロパティに該当する Movie テーブルのフィールドの型は、上のコードの InitialCreate クラスの指定通り TEXT となっています。

(8) プロジェクトの実行

上記 (7) まででアプリは完成です。プロジェクトを実行して Create 画面を表示して 2 つレコードを追加し、Index 画面でその一覧を表示したのがこの記事の一番上の画像です。

SQLite の Movie テーブルにも追加結果が反映されています。

レコードの Create 結果

Movie クラスの ReleaseDate プロパティ、Price プロパティの型はそれぞれ DateTime、deciaml で、SQLite の当該フィールドの型は TEXT ですが、フレームワークが型変換をしてくれているようです。(どこでどのように変換しているかは不明です。今後の検討課題ということで)

Tags: , , , ,

CORE

About this blog

2010年5月にこのブログを立ち上げました。その後 ブログ2 を追加し、ここは ASP.NET 関係のトピックス、ブログ2はそれ以外のトピックスに分けました。

Calendar

<<  November 2021  >>
MoTuWeThFrSaSu
25262728293031
1234567
891011121314
15161718192021
22232425262728
293012345

View posts in large calendar