WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

画像をアップロードして SQL Server に保存 (CORE)

by WebSurfer 24. November 2021 14:51

画像ファイルをタイトルと説明の文字列と共にアップロードし、サーバ側でサムネイル画像を作成してタイトル・説明・サムネイル画像・オリジナル画像を一式 SQL Server データベースに保存するサンプルを書きます。

一覧の表示

上の画像はアップロードして SQL Server に保存されたタイトル、説明、サムネイル画像を取得して一覧にして表示したものです。(オリジナル画像を表示してないのは一覧表に表示するのは大きすぎるからという理由だけです)

以下に、Entity Framework Code First の機能を使っての SQL Server データベースの作り方、一覧の表示、アップロード、編集、削除機能を実装した ASP.NET Core MVC アプリの作り方を述べます。

(保存先をデータベースではなく Web サーバーの特定のフォルダにファイルとして保存する場合は別の記事「ASP.NET Core MVC でファイルアップロード」に書きましたので、興味がありましたらそちらを見てください)

ベースに使ったプロジェクトは、先の記事「Visual Studio 2022 の ASP.NET MVC アプリ」に書いた Visual Studio 2022 で作成した .NET 6.0 の ASP.NET Core MVC アプリです。それに以下のように機能を追加します。

(1) Model

アップロードの際に Controller と View の間でデータのやり取りをするための View Model と、Entity Framework を使って SQL Server データベースとのデータのやり取りをするエンティティクラスを作ります。この記事では以下のようにしました (ファイル名とか MIME タイプ情報なども保持したいかもしれませんが、それはまた別の機会に)。

#nullable disable

using System.ComponentModel.DataAnnotations;

namespace MvcCore6App.Models
{
    // View Model
    // アップロードの際 Controller/View 間でデータのやり取りをする
    public class FileUploadViewModel
    {
        [Display(Name = "タイトル")]
        [Required(ErrorMessage = "{0} は必須")]
        [StringLength(25, ErrorMessage = "{0} は 25 文字以内")]
        public string Title { get; set; }

        [Display(Name = "説明")]
        [StringLength(250, ErrorMessage = "{0} は 25 文字以内")]
        public string Description { get; set; }

        [Display(Name = "ファイル")]
        [Required(ErrorMessage = "{0} は必須")]
        public IFormFile PostedFile { get; set; }
    }

    // エンティティクラス
    // SQL Server データベースとのデータのやり取りをする。また、
    // これをベースに EF Code First でデータベースを生成する
    public class FileEntity
    {
        public int Id { get; set; }

        [Display(Name = "タイトル")]
        [Required]
        [StringLength(25)]
        public string FileName { get; set; }

        [Display(Name = "説明")]
        [StringLength(250)]
        public string Description { get; set; }

        [Display(Name = "サムネイル画像")]
        [Required]
        public byte[] ThumbImage { get; set; }

        [Required]
        public byte[] OriginalImage { get; set; }
    }
}

Visual Studio 2022 で作成した .NET 6.0 の ASP.NET Core MVC アプリでは NULL 許容参照型がプロジェクト全体で有効化されていますので、警告を抑制するため #nullable disable を付与しています。下のコードでも必要に応じてそのようにしています。

(2) コンテキストクラス

Entity Framework を使って Controller と SQL Server データベースとの間でデータをやり取りするためのコンテキストクラスを定義します。この記事では以下のようにしました。

#nullable disable

using MvcCore6App.Models;
using Microsoft.EntityFrameworkCore;

namespace MvcCore6App.Data
{
    public class FileContext : DbContext
    {
        public FileContext(DbContextOptions<FileContext> options) : base(options)
        {
        }

        public DbSet<FileEntity> Files { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<FileEntity>().ToTable("File");
        }
    }

(3) Program.cs にサービスの追加

上記 (2) で定義したコンテクストクラスのインスタンスを Controller のコンストラクタ経由で DI できるようにするため、Program.cs に AddDbContext メソッドを使ってサービスの追加を行います。以下のコードで「// これを追加」とコメントした部分です。

using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using MvcCore6App.Data;
using MvcCore6App.Areas.Identity.Data;

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration
    .GetConnectionString("ApplicationDbContextConnection");

builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));

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

// これを追加
builder.Services.AddDbContext<FileContext>(options =>
    options.UseSqlServer(connectionString));

// Add services to the container.
builder.Services.AddControllersWithViews();

// ・・・後略・・・

(4) SQL Server データベース作成

上に定義したエンティティクラスとコンテクストクラスをベースに Migration 操作を行って SQL Server データベースを生成します。

SQL Server データベース

具体的には、Visual Studio のパッケージマネージャーコンソールで Add-Migration, Update-Database コマンドを実行すれば、Entity Framework Code First の機能によって、上の画像のようなデータベースが生成されます。

なお、上の (3) で接続文字列を appsettings.json に既存の ASP.NET Identity 用の "ApplicationDbContextConnection" にしていますので、ASP.NET Identity のユーザー情報のストア用のデータベースに File テーブルが追加されます。別のデータベースとして作成したい場合は接続文字列を変更してください。

(5) サムネイル作成用ユーティリティ

オリジナル画像を指定したサイズに縮小したサムネイル画像を作成するユーティリティクラスを定義します。

今回は以前に作成した .NET Framework の Windows アプリ用のコードを流用したのですが、それは Windows OS の GDI+ に依存する System.Drawing 名前空間のグラフィックス機能を利用しています。

ところが .NET Core ではそのような特定の OS に依存するものはデフォルトでは利用できないようです。なので、NuGet パッケージ System.Drawing.Common をインストールして対応します。

System.Drawing.Common

Windows OS の GDI+ に依存するということで Linux 上で動かすと例外が出て動かないとのことですが、それを解決するために libgdiplus というライブラリがあるそうです。(未検証・未確認です)

NuGet パッケージ System.Drawing.Common をインストールしても CA1416 警告が出ますが、#pragma warning disable CA1416 を追記して警告を抑制しました。コードは以下の通りです。

#pragma warning disable CA1416

using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;

namespace MvcCore6App.Utils
{
    public class ImageUtils
    {
        const int sizeThumb = 69;   // thumbimage のサイズ(縦横同じ)
        const int sizeLarge = 400;  // largerimage のサイズ(横幅)

        // sizeThumb で指定されたサイズのサムネイルを作る。
        // オリジナルの縦横比は保たれる(高さ or 幅の大きい方が sizeThumb になる)
        public static byte[] MakeThumb(byte[] fullsize)
        {
            // ・・・省略・・・
        }

        // 引数 newWidth, newHeight で指定されたサイズのサムネイルを作る。
        // 縦横で縮小率が異なる場合変形されないよう大きい方をトリミングして縮小
        public static byte[] MakeThumb(byte[] fullsize, int newWidth, int newHeight)
        {
            using (MemoryStream ms1 = new MemoryStream(fullsize))
            using (Image iOriginal = Image.FromStream(ms1))
            {                                
                // オリジナル/サムネイルの縦横のサイズ比
                double scaleW = (double)iOriginal.Width / (double)newWidth;
                double scaleH = (double)iOriginal.Height / (double)newHeight;

                // オリジナル画像をトリミングするための Rectangle 作成
                Rectangle srcRect = new Rectangle();

                if (scaleH == scaleW)  // 縦横同じ⇒トリミングなし
                {
                    srcRect.Width = iOriginal.Width;
                    srcRect.Height = iOriginal.Height;
                    srcRect.X = 0;
                    srcRect.Y = 0;
                }
                else if (scaleH > scaleW) // 縦 > 横 ⇒ 縦のみトリミング
                {
                    srcRect.Width = iOriginal.Width;
                    srcRect.Height = Convert.ToInt32((double)newHeight * scaleW);
                    srcRect.X = 0;
                    srcRect.Y = (iOriginal.Height - srcRect.Height) / 2;
                }
                else   // 縦 < 横 ⇒ 横のみトリミング
                {
                    srcRect.Width = Convert.ToInt32((double)newWidth * scaleH);
                    srcRect.Height = iOriginal.Height;
                    srcRect.X = (iOriginal.Width - srcRect.Width) / 2;
                    srcRect.Y = 0;
                }

                using (Image iThumb = new Bitmap(newWidth, newHeight))
                using (Graphics g = Graphics.FromImage(iThumb))
                {
                    g.InterpolationMode = InterpolationMode.HighQualityBicubic;
                    Rectangle destRect = new Rectangle(0, 0, newWidth, newHeight);
                    g.DrawImage(iOriginal, destRect, srcRect, GraphicsUnit.Pixel);

                    using (MemoryStream ms2 = new MemoryStream())
                    {
                        iThumb.Save(ms2, ImageFormat.Jpeg);
                        return ms2.GetBuffer();
                    }
                }
            }
        }

        // 幅のみ指定してサムネイルを作る。高さは幅と同じ縮小率で縮小。
        public static byte[] MakeThumb(byte[] fullsize, int maxWidth)
        {
            // ・・・省略・・・
        }
    }
}

引数 fullsize のバイト列が有効なイメージ形式でないと Image.FromStream で例外がスローされます。一応 png と jpeg 形式は問題ないのは確認しましたが、その他はチェックはしてないので注意してください。戻り値のサムネイル画像のバイト列は jpeg 形式になります。

(6) Controller / Action Method

SQL Server のレコード一覧の表示、アップロード、編集、削除を行うための Controller のコードは以下のようにしました。

using Microsoft.AspNetCore.Mvc;
using MvcCore6App.Data;
using MvcCore6App.Models;
using MvcCore6App.Utils;
using Microsoft.EntityFrameworkCore;

namespace MvcCore6App.Controllers
{
    public class FileController : Controller
    {
        private readonly FileContext _context;

        public FileController(FileContext context)
        {
            _context = context;
        }

        public async Task<IActionResult> Index()
        {
            var fileContext = _context.Files;
            return View(await fileContext.ToListAsync());
        }

        public async Task<IActionResult> GetThumb(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var file = await _context.Files
                .FirstOrDefaultAsync(m => m.Id == id);

            if (file == null)
            {
                return NotFound();
            }

            return File(file.ThumbImage, "image/jpeg");
        }


        public IActionResult Upload()
        {
            return View();
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Upload(FileUploadViewModel model)
        {
            if (ModelState.IsValid)
            {
                using (var memoryStream = new MemoryStream())
                {
                    await model.PostedFile.CopyToAsync(memoryStream);

                    // Upload the file if less than 2 MB
                    if (memoryStream.Length < 2097152)
                    {
                        var byteArray = memoryStream.ToArray();
                        var file = new FileEntity()
                        {
                            FileName = model.Title,
                            Description = model.Description,
                            ThumbImage = ImageUtils.MakeThumb(byteArray, 70, 70),
                            OriginalImage = byteArray
                        };

                        _context.Files.Add(file);

                        await _context.SaveChangesAsync();
                    }
                    else
                    {
                        ModelState.AddModelError("PostedFile", "サイズは 2MB 以下");
                        return View(model);
                    }
                }

                return RedirectToAction("Index");
            }

            return View(model);
        }

        public async Task<IActionResult> Edit(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var file = await _context.Files.FindAsync(id);
            if (file == null)
            {
                return NotFound();
            }
            return View(file);
        }

        [HttpPost, ActionName("Edit")]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> EditPost(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var fileToUpdate = await _context
                                     .Files
                                     .FirstOrDefaultAsync(f => f.Id == id);

            if (fileToUpdate != null)
            {

                if (await TryUpdateModelAsync<FileEntity>(
                    fileToUpdate,
                    "",
                    f => f.FileName, f => f.Description))
                {
                    await _context.SaveChangesAsync();
                    return RedirectToAction("Index");
                }

            }
            return View(fileToUpdate);
        }

        public async Task<IActionResult> Delete(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var file = await _context.Files
                .AsNoTracking()
                .FirstOrDefaultAsync(f => f.Id == id);

            if (file == null)
            {
                return NotFound();
            }

            return View(file);
        }

        // POST: Students/Delete/5
        [HttpPost, ActionName("Delete")]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> DeleteConfirmed(int id)
        {
            var file = await _context.Files.FindAsync(id);

            if (file == null)
            {
                return RedirectToAction("Index");
            }

            _context.Files.Remove(file);
            await _context.SaveChangesAsync();
            return RedirectToAction("Index");
            
            
        }
    }
}

Upload メソッドは Microsoft のドキュメント「バッファー モデル バインドを使用して小さいファイルをデータベースにアップロードする」を参考にしました。Action Method と View の間のデータのやり取りにはビューモデル FileUploadViewModel を使用し、それをエンティティクラス FileEntity に移し替えるとともにサムネイル画像を追加し SQL Server データベースに保存しています。

アップロードするファイルの検証については、アップロードする時点でのファイル選択の有無とサーバー側でのサイズのチェックしか行っていません。Microsoft のドキュメント「ASP.NET Core でファイルをアップロードする」の「セキュリティに関する考慮事項」のセクションに書かれたセキュリティ関する配慮はしていませんので注意してください。

先の記事「ファイルアップロード時の検証 (CORE)」に、ASP.NET Core. 3.1 MVC アプリでファイルをアップロードする際に、カスタム検証属性を利用してファイルのサイズとタイプをクライアント側とサーバー側の両方で検証し、検証結果 NG の場合はエラーメッセージを表示する方法を書きましたので、興味があれば見てください。

Edit / EditPost メソッドではタイトルと説明のみ編集して結果を SQL Server データベースに反映するようにしました。画像の差し替えは上のコードではできません (画像を差し替えるなら、削除してから新たに Upload し直した方が良いと思いましたので)。

タイトルと説明のみ変更するため TryUpdateModelAsync メソッドを使っているところに注目してください。よくあるパターンとしては、上の EditPost メソッドの引数に ビューモデル FileUploadViewModel を使ってそれにモデルバインドということをすると思いますが、画像データはアップロードされてこないところが問題です。

上の EditPost メソッドのコードでは、既存のエンティティを読み取り、TryUpdateModel を呼び出してポストされたタイトルと説明からフィールドを更新しています。既存のエンティティの読み取りによって画像データも取得されるので、その上でタイトルと説明だけを変更して SaveChanges を適用するので画像データが消えることはないです。

TryUpdateModelAsync メソッドについては Microsoft のチュートリアルの「HttpPost Edit メソッドの更新」が参考になると思いますので興味があれば見てください。

GetThumb メソッドは、DB からサムネイル画像を取得してダウンロードするためのメソッドです。この記事の一番上の一覧表示の画像ようにサムネイルを表示するために使います。View に img 要素を配置し、その src 属性に GetThumb メソッドを設定することによりサムネイル画像が表示されます。

(7) View

Index.cshtml, Upload.cshtml, Edit.cshtml, Delete.cshtml のコードをその順に以下に記載しておきます。Index.cshtml と Delete.cshtml には上のコントローラーのコードの GetThumb メソッドを使ってサムネイル画像を表示するようにしています。

Index.cshtml

@model IEnumerable<MvcCore6App.Models.FileEntity>

@{
    ViewData["Title"] = "Index";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<p>
    <a asp-action="Upload">Uoload New</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.FileName)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Description)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.ThumbImage)
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
@{
#nullable disable
    foreach (var item in Model) {
        <tr>
            <td>
                @Html.DisplayFor(modelItem => item.FileName)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Description)
            </td>
            <td>
                <img src="/File/GetThumb/@item.Id" alt="@item.FileName" 
                    title="@item.FileName" />
            </td>
            
            <td>
                <a asp-action="Edit" asp-route-id="@item.Id">Edit</a> |
                <a asp-action="Delete" asp-route-id="@item.Id">Delete</a>
            </td>
        </tr>
    }
}
    </tbody>
</table>

Upload.cshtml

@model MvcCore6App.Models.FileUploadViewModel

@{
    ViewData["Title"] = "Upload";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h1>Upload</h1>

<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="Upload" enctype="multipart/form-data" method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="Title" class="control-label"></label>
                <input asp-for="Title" class="form-control" />
                <span asp-validation-for="Title" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Description" class="control-label"></label>
                <input asp-for="Description" class="form-control" />
                <span asp-validation-for="Description" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="PostedFile" class="control-label"></label>
                <input asp-for="PostedFile" type="file" class="form-control" />
                <span asp-validation-for="PostedFile" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

<div>
    <a asp-action="Index">Back to List</a>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

Edit.cshtml

@model MvcCore6App.Models.FileEntity

@{
    ViewData["Title"] = "Edit";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h1>Edit</h1>

<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="Edit">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Id" />
            <div class="form-group">
                <label asp-for="FileName" class="control-label"></label>
                <input asp-for="FileName" class="form-control" />
                <span asp-validation-for="FileName" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Description" class="control-label"></label>
                <input asp-for="Description" class="form-control" />
                <span asp-validation-for="Description" class="text-danger"></span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

<div>
    <a asp-action="Index">Back to List</a>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

Delete.cshtml

@model MvcCore6App.Models.FileEntity

@{
    ViewData["Title"] = "Delete";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h1>Delete</h1>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>FileEntity</h4>
    <hr />
    <dl class="row">
        <dt class = "col-sm-2">
            @Html.DisplayNameFor(model => model.FileName)
        </dt>
        <dd class = "col-sm-10">
            @Html.DisplayFor(model => model.FileName)
        </dd>
        <dt class = "col-sm-2">
            @Html.DisplayNameFor(model => model.Description)
        </dt>
        <dd class = "col-sm-10">
            @Html.DisplayFor(model => model.Description)
        </dd>
        <dt class = "col-sm-2">
            @Html.DisplayNameFor(model => model.ThumbImage)
        </dt>
        <dd class = "col-sm-10">
            @{
                if (Model != null)
                {
                    <img src="/File/GetThumb/@Model.Id" 
                        alt="@Model.FileName" 
                        title="@Model.FileName" />
                }
            }
        </dd>
    </dl>
    
    <form asp-action="Delete">
        <input type="hidden" asp-for="Id" />
        <input type="submit" value="Delete"
            class="btn btn-danger" /> |
        <a asp-action="Index">Back to List</a>
    </form>
</div>

Tags: , , , ,

CORE

Visual Studio 2022 の ASP.NET MVC アプリ

by WebSurfer 22. November 2021 16:28

2021 年 11 月 8 日にリリースされた Visual Studio Community 2022 を自分の PC にダウンロードして使えるように設定しました。

Visual Studio Community 2022

まず手始めに、テンプレートを使って .NET 6.0 の ASP.NET Core MVC アプリのプロジェクトを作ってみましたので、Visual Studio 2019 との違いなど気が付いた点を備忘録として書いておきます。

新しいプロジェクトの作成

この記事を書いた時点での Visual Studio のバージョンは 17.0.1、.NET SDK のバージョンは 6.0.100、.NET Runtime のバージョンは 6.0.0 です。

(1) 開発サーバー

デフォルトで Kestrel をエッジサーバーとして使うようになってました。

開発サーバー

なので、その設定のまま Visual Studio からアプリを起動すると以下のように dotnet run コマンドによってアプリが Kestrel で実行され、

dotnet コマンド

処理された応答が自動的にブラウザに表示されます。(注: 自動的にブラウザに表示されるのは Visual Studio を使った場合です)

ちなみに、Visual Studio 2019 ではデフォルトでは IIS Express を使用するインプロセスホスティングモデルになります。

詳しくは先の記事「開発環境で Kestrel 利用 (CORE)」を見てください。

(2) Bootstrap

Bootstrap.css, Bootstrap.js のバージョンが 5.1.0 にアップグレードされています。

ちなみに、Visual Studio 2019 で .NET 5.0 の ASP.NET Core MVC アプリを作った場合のバージョンは 4.3.1 になります。

自分が気が付いた限りですが、特に大きく違うのがラベルとテキストボックスの表示でした。他にも違いは多々あると思いますが。

Login 画面

他に、Bootstrap.js が jQuery に依存しなくなったことも大きな違いだと思います。

(3) Startup.cs / Program.cs

Startup.cs が無くなっていました。Visual Studio 2019 ではそれにサービス、ミドルウェア追加のためのコードを書いていたのですが、それらは Program.cs に移すことにしたらしいです。

Program.cs にエントリーポイントとして存在しなければならないはずの Main メソッドが見当たりません。Microsoft の「新しい C# テンプレートで、最上位レベルのステートメントが生成される」はコンソールアプリのドキュメントですが、ここに書いてあることが ASP.NET Core MVC にも適用されているようです。

(4) NULL 許容参照型

NULL 許容参照型がプロジェクト全体で有効化されています。

NULL 許容参照型

ASP.NET Core Identity 関係の .cshtml ファイルのソースコードにそれに対応してない部分があって警告が出ます。(.cshtml.cs ファイルの方は #nullable disable の付与で対応済み)

自分が試した時には Logout.cshtml, Login.cshtml, Register.cshtml, _Layout.cshtml の 4 つのファイルで警告が出ました。前の 3 つは #nullable disable の追加、 _Layout.cshtml は (string) ⇒ (string?) に変更で対処できます。

(5) CSS 分離

.NET 5.0 の Blazor アプリで導入された CSS 分離のための仕組みが MVC アプリにも導入されました。下の画像は Views/Shared/_Layout.cshtml の一部ですが、赤枠で囲ったコードがその機能を使うためのものです。

CSS 分離

Microsoft のドキュメント「ASP.NET Core Blazor の CSS の分離」によると、CSS スタイルを個々のビューに分離して、次のことを回避するのが目的だそうです。

  • 維持が困難なグローバル スタイルへの依存関係
  • 入れ子になったコンテンツでのスタイルの競合

具体的には、以下のようにビューと同じ名前の css ファイルを作成し、

CSS ファイル

このセクションの一番上の画像のように <link ... href="~/<プロジェクト名>.styles.css" ... という外部 css ファイルへの参照をコードに含めると、それからダウンロードされる css ファイルには以下のように属性セレクタが含まれるようになり、

CSS の属性セレクタ

当該ビュー(上の例では _Layout.cshtml)の html 要素には上の css の属性セレクタに設定された属性が付与されます。

html 要素の属性

その結果、css スタイルを個々のビューに分離することができます。

(6) ブラウザーリンクとファイルウォッチャー

html ソースを見ると以下のコードが自動的に(勝手に)生成されています。

ブラウザーリンクとファイルウォッチャー

前者は名前からすると Visual Studio 2015 にもあったブラウザーリンクという機能らしいです。Visual Studio 2019/2022 ではデフォルトで無効になっています。なのにスクリプトファイルを参照しているのは何故か、どのように使うのかは不明・未確認です。

後者はファイルウォッチャートという機能らしいです。aspnetcore-browser-refresh.js で検索すると「ファイル ウォッチャーを使用した ASP.NET Core アプリの開発」という記事が 見つかります。

その記事の「ブラウザーの更新」のセクションに "開発中のこのような場合には、アプリに手動でスクリプトを挿入します。 たとえば、Web アプリを構成してスクリプトを手動で挿入するには、_framework/aspnet-browser-refresh.js を含むようにレイアウト ファイルを更新します。" と書いてあります。

Visual Studio 2022 で提供されたホットリロードというデバッグ中にコードを変更しながら開発を行うことを可能にした機能を実現するために使うものらしいですが、詳細は調べ切れてなくて不明です。

これらをどのように利用できるかは今後の課題ということで・・・

Tags: , ,

CORE

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

About this blog

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

Calendar

<<  December 2021  >>
MoTuWeThFrSaSu
293012345
6789101112
13141516171819
20212223242526
272829303112
3456789

View posts in large calendar