認証とセッション

 認証とセッション⑥

 C#とIdentityで、ログインからログアウトまで作る

前回は、セッションIDと認証チケットの違い、CookieとJWTの役割を整理しました。今回は、ASP.NET Core Identityを使い、認証チケットをCookieで受け渡すアプリをC#で作ります。

ログイン画面でユーザー名とパスワードを入力する。認証に成功すると、ユーザー名が表示される。ページを再読み込みしても、毎回パスワードを入力する必要はありません。

実際にサンプルを作り、ログインできるところまで動かしてみました。

ところが、一つ気になる点がありました。「このブラウザーでログイン状態を保持する」にチェックを入れても、入れなくても、試した範囲では見た目の動作が同じだったのです。

チェックの有無は、どこに反映されているのでしょうか。

今回は、まずログインからログアウトまでを実装し、その後でブラウザーが受け取ったCookieを観察します。さらに、保存されているファイルを開き、画面だけでは分からなかった違いを調べるところまで進めます。

ログインの基本動作に加え、チェックの有無によるCookieの属性の違いと、永続Cookieの保存ファイルを実機で確認しました。

ログインの前と後では、確かめるものが変わる

最初のログインでは、「このユーザー名に対するパスワードは正しいか」を確かめます。

成功したら、ユーザーIDやユーザー名などを含む認証情報を作り、暗号化と改ざん検知によって保護した認証Cookieとしてブラウザーへ渡します。ここに入力パスワードを保存する必要はありません。

次のページを開くと、ブラウザーは送信条件に合うCookieを自動で添えます。サーバーはそれを検証し、認証情報を復元してから、そのページへのアクセスを許可するか判断します。

場面サーバーが確認するもの
ログイン時入力されたユーザー名とパスワード
ログイン後のアクセス認証Cookieの内容と有効性、そのページのアクセス条件

「パスワードを確認する」と「ログインした状態を引き継ぐ」は、別の処理なのです。

Identityに任せる部分を決める

ASP.NET Core Identityは、ユーザー管理やパスワードの検証、サインインなどを扱う仕組みです。今回のサンプルでは、パスワードのハッシュ計算やCookieの暗号処理を自作せず、IdentityとCookie認証の機能に任せます。[1]

登場する主な部品は、次の四つです。

部品今回の役割
UserManagerユーザーの登録やパスワードの管理
SignInManagerパスワードを検証し、サインインを進める
Cookie認証ハンドラー認証Cookieの発行・検証
Authorize属性その画面に認証が必要であることを指定

以下は.NET 10のASP.NET Core MVCを想定した、認証の流れを説明するサンプルです。DBと検証用ユーザーを準備するところから始めます。登録画面、二要素認証、パスワード再設定の画面は省略します。サンプルはローカルの開発環境で試すためのものです。

プロジェクト名・名前空間は CookieLoginSample とします。DBにはSQLiteを使い、Microsoft.AspNetCore.Identity.EntityFrameworkCore と Microsoft.EntityFrameworkCore.Sqlite の10.0系パッケージが必要です。DBの作成には、対応するEF CoreのDesignパッケージと dotnet-ef を使います。

MVCプロジェクトを用意する

.NET 10 SDKをインストールしたWindowsのPowerShellなどで、次のコマンドを実行します。認証なしのMVCテンプレートへ、必要なIdentityの機能を追加します。

PowerShell
chdir ~
dotnet new mvc -n CookieLoginSample -f net10.0
cd CookieLoginSample
dotnet new sln -n CookieLoginSample --format sln
dotnet sln CookieLoginSample.sln add CookieLoginSample.csproj
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore --version "10.0.*"
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version "10.0.*"
dotnet add package Microsoft.EntityFrameworkCore.Design --version "10.0.*"
dotnet new tool-manifest
dotnet tool install dotnet-ef --version "10.0.*"
dotnet dev-certs https --trust

最初のchdir ~は、自分のユーザーフォルダーへ移動する操作です。PowerShellを開いた場所によっては書き込みに適さないフォルダーにいるため、作業を始める場所を明示しています。すでに自分の開発用フォルダーがあれば、そこへ移動して構いません。

dotnet new mvcで、ソースファイルとCookieLoginSample.csprojが作られます。続いてソリューションファイルを作り、プロジェクトを登録します。ここではファイル名を分かりやすくするため、従来の.sln形式を指定しました。[6]

生成されたCookieLoginSample.slnを、.NET 10に対応したVisual Studioで開きます。これ以降は、ソリューションエクスプローラーでフォルダーやファイルを追加し、コードを編集できます。Visual Studioには「ASP.NETとWeb開発」のワークロードを用意してください。

Visual Studioの「新しいプロジェクトの作成」から始める場合は、「ASP.NET Core Webアプリ(モデル ビュー コントローラー)」を選び、プロジェクト名をCookieLoginSample、認証の種類を「なし」にします。その場合はプロジェクトとソリューションができているため、上の作成コマンドを重ねて実行する必要はありません。

dotnet add packageは、NuGetから必要なライブラリを追加する操作です。NuGetは.NETのライブラリを配布・管理する仕組みで、ここでは別のNuGet用ソフトをインストールする必要はありません。Visual Studioの「NuGetパッケージの管理」から追加することもできます。

追加するもの役割
Microsoft.AspNetCore.Identity.EntityFrameworkCoreIdentityのユーザー情報をEF Core経由でDBに保存する
Microsoft.EntityFrameworkCore.SqliteEF CoreからSQLiteを利用する
Microsoft.EntityFrameworkCore.DesignDB構造の作成・更新に必要な設計時の機能を提供する
dotnet-efマイグレーションやDB更新をコマンドで実行する

3つのパッケージとEFツールは10.0系でそろえます。作業中にエラーが出た場合は、まず各コマンドの成功を確認します。プロジェクトフォルダーでdotnet list packageを実行すると、追加済みのパッケージを確認できます。

以下のファイルを作成・置換します。テンプレートのほかのファイルは残して構いません。DBを作るコマンドは、コードの配置が終わってから実行します。

ファイル役割
Program.csDB・Identity・Cookieの設定と処理順序
Data/ApplicationDbContext.csIdentityのユーザーDBへの接続
Data/DevelopmentUser.cs検証用ユーザーを一度だけ登録
Models/LoginModel.csログインフォームの入力
Controllers/AuthController.csログイン画面・サインイン・ログアウト
Controllers/HomeController.cs認証が必要な画面
Views/Auth/Index.cshtmlログインフォーム
Views/Home/Index.cshtmlログイン後の表示
Views/_ViewImports.cshtmlRazorのタグヘルパーを有効化

今回のDBは、ファイルとして使うSQLite

普段SQL Serverなどを使っていると、「DBをインストールしていないのに、このまま進んでよいのか」と感じるかもしれません。

今回使うSQLiteは、アプリからライブラリを通して利用するDBです。別のDBサーバーを起動して接続する構成ではなく、テーブルやデータをファイルに保存します。[12]

このサンプルでは、後ほど次の接続先を指定します。

Plaintext
Data Source=identity.db

identity.dbがDBファイルです。この記事のようにプロジェクトフォルダーからコマンドを実行すると、その作業フォルダーに作成されます。相対パスなので、実行場所を変えれば保存先も変わる点には注意してください。

SQLiteを使うライブラリは、先ほどのパッケージ追加で準備します。その後、dotnet ef database updateでDBファイルとIdentity用のテーブルを作ります。

このDBにはユーザー名やパスワードハッシュなどが保存されます。ブラウザーが保存するCookieは、別の場所にあります。

起動時に、認証のルールと処理の順序を登録する

アプリが起動したとき、ログイン先やCookieの有効期間、リクエストを処理する順序を登録します。

Startup.cs の ConfigureServices と Configure に分けて書く構成もありますが、ここでは設定を Program.cs にまとめます。

まず、Identityのユーザー情報を保存するDBとの接続窓口を用意します。

Data/ApplicationDbContext.cs

C#
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace CookieLoginSample.Data;

public class ApplicationDbContext : IdentityDbContext<IdentityUser>
{
    public ApplicationDbContext(
        DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }
}

続いて、IdentityとCookie認証を登録します。

Program.cs

C#
using CookieLoginSample.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlite("Data Source=identity.db"));

builder.Services
    .AddIdentity<IdentityUser, IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultTokenProviders();

builder.Services.ConfigureApplicationCookie(options =>
{
    options.LoginPath = "/Auth";
    options.AccessDeniedPath = "/Auth/AccessDenied";
    options.Cookie.Name = "Sample.Auth";
    options.Cookie.HttpOnly = true;
    options.Cookie.SameSite = SameSiteMode.Lax;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.ExpireTimeSpan = TimeSpan.FromMinutes(30);
    options.SlidingExpiration = true;
});

var app = builder.Build();

// 明示的なコマンドでだけ、開発用ユーザーを登録する
if (args.Contains("--seed-user"))
{
    if (!app.Environment.IsDevelopment())
        throw new InvalidOperationException("開発環境でのみ実行できます。");

    await DevelopmentUser.CreateAsync(app.Services, app.Configuration);
    return;
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();

app.UseAuthentication(); // Cookieを検証し、Userを設定する
app.UseAuthorization();  // ページのアクセス条件を判定する

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

app.Run();

AddIdentity で認証に必要な機能を登録し、その後の ConfigureApplicationCookie でIdentityが使う認証Cookieを設定しています。[2]

ここで重要なのが、次の二つの役割です。

C#
app.UseAuthentication();
app.UseAuthorization();

前者は「誰として認証されているか」を復元する処理、後者は「そのユーザーが、このページを利用できるか」を判断する処理です。[3]

起動時にこのコードを実行すると、リクエストを受けたときに動く処理が登録されます。Program.cs 自身がアクセスのたびにログインを判定し続けるわけではありません。

検証用ユーザーを登録する

ログインするには、あらかじめユーザーの登録が必要です。今回は登録画面を増やさず、開発時のコマンドで一度だけ作成します。

Data/DevelopmentUser.cs

C#
using Microsoft.AspNetCore.Identity;

namespace CookieLoginSample.Data;

public static class DevelopmentUser
{
    public static async Task CreateAsync(
        IServiceProvider services, IConfiguration configuration)
    {
        using var scope = services.CreateScope();
        var users = scope.ServiceProvider
            .GetRequiredService<UserManager<IdentityUser>>();

        const string userName = "demo";
        if (await users.FindByNameAsync(userName) is not null)
        {
            Console.WriteLine("demo は登録済みです。変更しません。");
            return;
        }

        var password = configuration["Seed:Password"];
        if (string.IsNullOrWhiteSpace(password))
            throw new InvalidOperationException("Seed:Passwordを設定してください。");

        var result = await users.CreateAsync(
            new IdentityUser { UserName = userName }, password);

        if (!result.Succeeded)
            throw new InvalidOperationException(string.Join(" / ",
                result.Errors.Select(error => error.Description)));

        Console.WriteLine("demo を登録しました。");
    }
}

CreateAsyncに渡すのは登録時のパスワードです。Identityが保存用のハッシュを作り、DBへ登録します。パスワードを自分でSHA-256にかけて保存するコードは書きません。

未認証のアクセスは、ログイン画面へ案内する

起動時の認証設定と、未認証ならログイン画面へ誘導する分岐
ルールは起動時に登録し、認証・認可の判定はアクセスごとに行います。

トップページは、ログインした人だけが開けるようにします。

Controllers/HomeController.cs

C#
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace CookieLoginSample.Controllers;

[Authorize]
public class HomeController : Controller
{
    public IActionResult Index()
    {
        return View();
    }
}

[Authorize] は、このコントローラーの利用に認証が必要だという指定です。

未認証のまま / を開いた場合、HomeController.Index() の中身を実行する前にアクセスが止まり、Cookie認証の仕組みがログイン先へ誘導します。通常のMVC画面では、たとえば次のURLへのリダイレクトになります。

Plaintext
/Auth?ReturnUrl=%2F

ReturnUrl は、ログイン後に戻りたい場所です。この場合は / を表します。

ブラウザーがそのURLへアクセスし直し、ログイン画面が表示されます。このサンプルではGETで直接画面を返します。

ログインフォームから、ユーザー名とパスワードを送る

フォームの入力を受け取るモデルを作ります。

Models/LoginModel.cs

C#
using System.ComponentModel.DataAnnotations;

namespace CookieLoginSample.Models;

public class LoginModel
{
    [Required]
    public string UserName { get; set; } = "";

    [Required]
    [DataType(DataType.Password)]
    public string Password { get; set; } = "";

    public bool RememberMe { get; set; }
    public string? ReturnUrl { get; set; }
}

Razorのタグヘルパーを利用するため、Views/_ViewImports.cshtml には次を記載しておきます。

Razor
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

ログイン画面は次のようになります。

Views/Auth/Index.cshtml

Razor
@model CookieLoginSample.Models.LoginModel

<h1>ログイン</h1>

<form asp-controller="Auth" asp-action="Index" method="post">
    <div asp-validation-summary="All"></div>
    <input asp-for="ReturnUrl" type="hidden" />

    <p>
        <label asp-for="UserName">ユーザー名</label>
        <input asp-for="UserName" autocomplete="username" />
    </p>
    <p>
        <label asp-for="Password">パスワード</label>
        <input asp-for="Password" autocomplete="current-password" />
    </p>
    <p>
        <label>
            <input asp-for="RememberMe" />
            このブラウザーでログイン状態を保持する
        </label>
    </p>

    <button type="submit">ログイン</button>
</form>

ログインボタンを押すと、フォームの内容がPOSTで送られます。MVCは、送信された項目名に対応するモデルのプロパティへ値を入れます。

UserName の入力が model.UserName、Password の入力が model.Password に入るので、コントローラーではそれらを使ってログインを進められます。

このフォームではタグヘルパーが偽造防止トークンも生成し、次に示すPOST側の属性で検証します。

PasswordSignInAsyncで、本人確認からサインインまで進める

Identityでパスワードを確認し、認証チケットを保護してCookieを発行する流れ
PasswordSignInAsyncは入力の検証からサインインまで進めます。

ログイン画面の表示とPOSTの受け取り、ログアウトを一つのコントローラーにまとめます。

Controllers/AuthController.cs

C#
using CookieLoginSample.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;

namespace CookieLoginSample.Controllers;

public class AuthController : Controller
{
    private readonly SignInManager<IdentityUser> _signInManager;

    public AuthController(SignInManager<IdentityUser> signInManager)
    {
        _signInManager = signInManager;
    }

    [HttpGet]
    [AllowAnonymous]
    public IActionResult Index(string? returnUrl = null)
    {
        return View(new LoginModel { ReturnUrl = returnUrl });
    }

    [HttpPost]
    [AllowAnonymous]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Index(LoginModel model)
    {
        if (!ModelState.IsValid)
            return View(model);

        var result = await _signInManager.PasswordSignInAsync(
            userName: model.UserName,
            password: model.Password,
            isPersistent: model.RememberMe,
            lockoutOnFailure: true);

        if (result.Succeeded)
        {
            // 外部サイトへのURLを、そのまま戻り先に使わない
            if (!string.IsNullOrEmpty(model.ReturnUrl)
                && Url.IsLocalUrl(model.ReturnUrl))
            {
                return LocalRedirect(model.ReturnUrl);
            }

            return RedirectToAction("Index", "Home");
        }

        // この例では二要素認証の画面は扱わない
        var message = result.RequiresTwoFactor
            ? "このアカウントには追加認証が必要です。"
            : result.IsLockedOut
                ? "一時的にログインが制限されています。"
                : "ログインできませんでした。入力内容を確認してください。";

        ModelState.AddModelError(string.Empty, message);
        return View(model);
    }

    [HttpPost]
    [Authorize]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Logout()
    {
        await _signInManager.SignOutAsync();
        return RedirectToAction(nameof(Index));
    }

    [HttpGet]
    [AllowAnonymous]
    public IActionResult AccessDenied()
    {
        return StatusCode(403, "このページを利用する権限がありません。");
    }
}

ログイン画面とログインのPOSTには、[AllowAnonymous] を付けています。ログインするための入口は、未認証でも利用できる必要があるためです。

処理の中心は、次の呼び出しです。

C#
var result = await _signInManager.PasswordSignInAsync(
    userName: model.UserName,
    password: model.Password,
    isPersistent: model.RememberMe,
    lockoutOnFailure: true);

指定したユーザーを探し、保存済みのパスワードハッシュを使って入力を検証します。成功すればサインインへ進み、Cookie認証の機能を通して認証Cookieがレスポンスに設定されます。[1][2]

つまり、単に「パスワードが一致したか」を返すだけのメソッドではありません。このサンプルでは、成功時のCookie発行までにつながっています。

認証Cookieと移動先は、一つのレスポンスで返せる

Set-CookieとLocationを同じ応答で受け取り、次のアクセスで認証情報を送る流れ
Cookieを保存したブラウザーが、移動先へのアクセスで送り返します。

ログインに成功すると、サーバーはブラウザーへ二つのことを伝えます。

一つは「この認証Cookieを保存して」、もう一つは「次はこのページを開いて」です。

概念的には、次のようなレスポンスになります。

HTTP
HTTP/1.1 302 Found
Location: /
Set-Cookie: Sample.Auth=<保護された認証情報>; path=/; secure; httponly

これは説明用に一部のヘッダーを省略した例です。実際のCookieには、設定に応じた属性が付きます。

ブラウザーはCookieを保存してから、移動先の / へアクセスします。そのリクエストには、今受け取ったCookieが付きます。

HTTP
GET / HTTP/1.1
Host: localhost:<起動時に表示されたポート>
Cookie: Sample.Auth=<保護された認証情報>

この段階で、パスワードをもう一度送っているわけではありません。

Cookie認証の処理が内容と有効期限などを検証し、ユーザー情報を HttpContext.User に設定します。その後、アクセス条件を満たせば HomeController.Index() が実行されます。[3]

画面では、復元されたユーザー情報を利用する

ログイン後の画面では、User.Identity.Name からユーザー名を参照できます。

Views/Home/Index.cshtml

Razor
<h1>ログイン後の画面</h1>

<p>@User.Identity?.Name さん、こんにちは。</p>

<form asp-controller="Auth" asp-action="Logout" method="post">
    <button type="submit">ログアウト</button>
</form>

ここで使っている User は、このリクエストを処理するために復元されたユーザー情報です。

次のページへ移動したときも、ブラウザーがCookieを送り、サーバーが検証し、そのリクエストのユーザー情報を設定します。

ログイン画面のコントローラーがずっと動いているわけでも、最初に作ったコントローラーを使い続けているわけでもありません。リクエストごとに認証情報を受け渡すことで、ログイン状態を引き継いでいます。

DBとユーザーを作り、実際にログインする

ここまでのファイルを保存したら、PowerShellでCookieLoginSample.csprojのあるフォルダーへ移動し、次を実行します。

PowerShell
dotnet ef migrations add InitialIdentity
dotnet ef database update
dotnet user-secrets init
dotnet user-secrets set "Seed:Password" "LocalDemo-Only!2026"
dotnet run --launch-profile https -- --seed-user
dotnet user-secrets remove "Seed:Password"
dotnet run --launch-profile https

例のパスワードはローカル検証専用です。demoと登録に使ったパスワードでログインします。--seed-userを付けた実行は登録だけ行って終了し、最後のコマンドでWebアプリを起動します。テンプレートのhttps起動プロファイルは開発環境として実行する設定です。

User Secretsは開発用の設定保管先であり、暗号化された金庫ではありません。ここでは検証用の入力をソースコードに埋め込まないために使い、登録後は削除しています。DBにはパスワードハッシュが残るため、設定を削除してもログインできます。

起動ログのNow listening on: https://localhost:...に表示されたHTTPSのURLをブラウザーで開きます。ポート番号は環境によって異なります。

未ログインならログイン画面へ移動します。ユーザー名demoと、登録に使ったパスワードを入力してください。成功すると「demo さん、こんにちは。」と表示されます。

ログインフォームにdemoを入力し、ログイン状態を保持するチェックボックスが表示された画面
未認証のアクセスは /Auth?ReturnUrl=%2F へ案内されます。
認証成功後にdemoさんへの挨拶とログアウトボタンが表示された画面
認証後はトップページに戻り、ユーザー名が表示されます。

この画面まで表示できたら、認証の基本動作を一通り追えています。次は、見えないところで何を受け渡しているかを確かめます。

チェックを付けても付けなくても、同じに見えた

ログイン画面には「このブラウザーでログイン状態を保持する」というチェックボックスがあります。

実際に試した範囲では、チェックを入れても入れなくてもログインでき、その後の見た目の動作も同じでした。ただし、この観察だけで「チェックが効いていない」とは判断できません。

コードでは、その値を次の引数へ渡しています。

C#
isPersistent: model.RememberMe

trueなら永続Cookieとして、falseならセッションCookieとして認証Cookieを発行します。どちらも、ログイン後のリクエストに添えて送れるCookieです。ページ移動や再読み込みの動作が同じでも、不思議ではありません。[3]

また、タブを一枚閉じることと、ブラウザーのセッションを終了することは別です。ブラウザーのセッション復元機能によって、終了前のセッションCookieが復元される場合もあります。[8]

今回、見た目の違いを確認できなかった理由がどれに当たるかは、まだ断定できません。そこで、画面の動きから推測するのではなく、発行されたCookieそのものを確認します。

開発者ツールで、Cookieの有効期限を見る

最初は保存ファイルではなく、ブラウザーが現在保持しているCookieを見ます。Edgeでサンプルアプリを開き、F12で開発者ツールを表示します。

「Application(アプリケーション)」から「Storage」内の「Cookies」を開き、サンプルアプリの接続先を選びます。探す名前は、Program.csで指定したSample.Authです。[7]

比較は、次の順番で行います。

  1. サンプルアプリのログアウトボタンを押す。
  2. チェックを外してログインする。
  3. Sample.Authの「Expires / Max-Age」を記録する。
  4. 再びログアウトする。
  5. 今度はチェックを入れてログインする。
  6. 同じ項目を比較する。

実際に確認すると、次の違いがありました。

Edgeの開発者ツールでSample.Authの期限がSessionと表示された画面
チェックなし:Expires / Max-Age は Session
Edgeの開発者ツールでSample.Authの期限が2026-10-24T07:42:03.687Zと表示された画面
チェックあり:Expires / Max-Age に有効期限の日時を表示

画像をクリックすると元のサイズで確認できます。比較するのは、認証CookieであるSample.Authの行です。

チェックisPersistentExpires / Max-Ageの表示
なしfalseSession
ありtrue有効期限の日時

ログイン後の画面は同じでした。しかし、Sample.Authの期限は、チェックなしでは「Session」、チェックありでは日時になりました。チェックの値は、Cookieの保存期限に反映されていたのです。上の行の.AspNetCore.Antiforgeryから始まるCookieは別用途なので、比較するのはSample.Authの行です。

差がなければ、NetworkのログインPOSTでRememberMeがどう送られたか、応答のSet-Cookieにexpiresが付くかを順に確認します。チェックボックスの値は、入力フォーム、モデル、isPersistent、Cookieの属性という順番で追えます。

HttpOnlyは、ページのJavaScriptからCookieを読み取れないようにする属性です。ブラウザーの開発者ツールで確認することまで禁止するものではありません。

Cookieの保存期限と、認証チケットの期限は別

Expires / Max-AgeがSessionだったとしても、認証が無期限に有効になるわけではありません。

ブラウザーがCookieをいつまで保持するかと、サーバーが認証チケットをいつまで受け付けるかは、分けて考えます。今回の設定は次のとおりです。

C#
options.ExpireTimeSpan = TimeSpan.FromMinutes(30);
options.SlidingExpiration = true;

チェックの有無にかかわらず、認証チケットには有効期限があります。この例では基本の有効期間を30分にしています。[3]

見ているもの意味
CookieのExpires / Max-Ageブラウザー側の保存期限
認証チケットの有効期限サーバーが認証情報を受け付ける期限

SlidingExpirationが有効なので、一定の条件を満たしたアクセス時にはチケットが更新され、Cookieが再発行されます。毎回のアクセスで必ず30分延びるわけではありません。[3]

「ログイン状態を保持する」は、ログインを永久に有効にするスイッチではないのです。

Cookieファイルを、実際に開いてみる

ブラウザー上で属性を確認できたら、今度は保存されているファイルを開いてみます。

Edgeでは、Cookieを一個ずつのテキストファイルにして保存しているわけではありません。複数のCookieを、SQLiteのデータベースにまとめて保存します。[9]

ここでSQLiteが再登場します。ただし、アプリ側のユーザーDBとは別物です。

ファイル管理している側保存するもの
identity.db今回作ったASP.NET Coreアプリユーザー情報・パスワードハッシュなど
CookiesEdge各サイトのCookieと、その属性

まずEdgeのアドレス欄へedge://versionと入力し、「プロフィール パス」を確認します。複数のプロフィールがあると保存先も変わるので、固定のパスを決め打ちせず、今使っている場所を確認します。[10]

Windowsの通常のEdge環境では、そのプロフィールフォルダー内のNetworkフォルダーに、拡張子のないCookiesファイルがあります。

Plaintext
<プロフィール パス>\Network\Cookies

最初は、保存結果を確認しやすい永続Cookieから調べます。

  1. 「ログイン状態を保持する」にチェックを入れてログインする。
  2. Edgeのウィンドウをすべて閉じる。ファイルをコピーできない場合は、タスクマネージャーで残っているMicrosoft Edgeのプロセスを終了する。
  3. Cookiesファイルを作業用フォルダーへコピーする。
  4. コピーしたファイルをSQLiteの閲覧ツールで開く。
  5. cookiesテーブルから、host_keyがlocalhost、nameがSample.Authの行を探す。

今回の検証では、Edgeのウィンドウを閉じただけではプロセスが残り、Cookiesファイルを使用中のまま保持していたため、コピーできませんでした。入力中の内容を保存してからウィンドウをすべて閉じ、Ctrl+Shift+Escでタスクマネージャーを開きます。「Microsoft Edge」が残っている場合は「タスクの終了」で終了し、その後にコピーしてください。通常の終了でコピーできる場合、この操作は不要です。

閲覧には、たとえばDB Browser for SQLiteを使えます。「データベースを開く」でコピーしたファイルを選び、「データ閲覧」でcookiesテーブルを表示します。拡張子のないファイルが一覧に出ない場合は、ファイルの種類を「すべてのファイル」に切り替えます。

操作するのはコピーです。ブラウザーが使っている元のDBへ変更を書き込まないようにします。終了後もCookies-walが残っている場合は、そのファイルもCookiesと同じ名前関係・同じフォルダー配置でコピーします。変更がWAL側に残っている可能性があるためです。[13]

次のSQLなら、Cookieの値全体を表示せず、属性と保存データの長さを確認できます。

SQL
SELECT
    host_key,
    name,
    path,
    is_persistent,
    has_expires,
    expires_utc,
    is_secure,
    is_httponly,
    length(value) AS value_length,
    length(encrypted_value) AS encrypted_value_length
FROM cookies
WHERE host_key = 'localhost'
  AND (name = 'Sample.Auth' OR name GLOB 'Sample.AuthC[0-9]*');
SQLiteでSample.Authを検索した結果。is_persistentとhas_expiresは1、value_lengthは0、encrypted_value_lengthは687。
保存ファイルでも、期限付きの永続Cookieとして記録されていました。クリックで元画像を表示します。

実際に検索すると、Sample.Authが1行見つかりました。is_persistentとhas_expiresはともに1で、Secure・HttpOnlyも1です。平文用のvalueの長さは0、encrypted_valueの長さは687バイトでした。永続Cookieとしての属性と、暗号化された値の保存を確認できました。セッションCookieの保存ファイルについては、今回の結果だけでは判断しません。

Cookieが大きいと分割されることがあるため、Sample.AuthC1などの名前も対象にしています。127.0.0.1など別のホスト名で開いた場合は、検索条件も合わせて変えます。

列見る内容
host_key / name / pathどのサイトの、どのCookieか
is_persistent永続Cookieとして扱うか
has_expires有効期限が設定されているか
expires_utc内部形式で保存された期限
is_secureHTTPS向けの属性
is_httponlyJavaScriptからの読み取りを制限する属性
value / encrypted_valueCookieの値を保存する領域

Chromiumの実装では、期限は1601年1月1日を基準としたマイクロ秒単位の値です。通常のUnix時刻として扱うと違う日付になります。まずは生の値と属性を確認し、日時としての確認は開発者ツールと対応づけると分かりやすくなります。[9]

ファイルにあったから、永続Cookieとは限らない

チェックを外した場合も、同じ手順で別のコピーを作って比較できます。ただし、終了時に対象のCookieが削除され、行が見つからないこともあります。

逆に、セッションCookieがDBに記録される場合もあります。ChromiumのCookie保存処理は、永続Cookieだけでなく、セッションCookieの保存も扱っています。[9]

そのため、「ディスク上にあったから永続Cookie」「ファイルになかったからログインに失敗した」とは判断しません。

まず、ログイン中の開発者ツールでSessionか期限付きかを確認する。そのうえで保存DBのis_persistentやhas_expires、終了後に行が残ったかを比較します。

観察する場所確認したいこと
ログインPOSTの送信内容RememberMeの選択が送られたか
応答のSet-Cookieサーバーが何を発行したか
開発者ツールのCookie一覧ブラウザーが現在どう保持しているか
終了後にコピーしたCookiesファイルディスクに何が保存されたか

同じ「Cookieを見る」でも、確認している段階が違います。ブラウザーを開き直したときの見た目だけでは分からなかった違いを、こうして分解して追っていきます。

保存された値は、そのままユーザー名として読める?

Sample.Authの行を見つけても、そこにdemoというユーザー名や入力したパスワードが、そのまま並んでいるわけではありません。

今回の認証Cookieには、ASP.NET CoreのData Protectionで保護された認証チケットが入っています。開発者ツールで見える長い文字列も、ユーザー情報を読みやすい形で並べたものではありません。[3]

さらにEdgeは、Cookieの値をディスクへ保存するときにも暗号化します。Windows版Edgeにも、保存用の暗号化キーをアプリに結び付けるApp-Bound Encryptionの仕組みがあります。適用状況は環境やポリシーによりますが、保存ファイルを開けばCookie値を平文で読めるという前提にはできません。[11]

保護する側保護しているもの
ASP.NET Core認証チケットの内容と改ざんへの耐性
Edgeディスクへ保存するCookieの値

サーバーで保護したチケットを、ブラウザーが保存時にさらに保護する構造です。Edgeが送信する際にはブラウザー側の保存用保護を外しますが、チケットに施されたアプリ側の保護は残っています。

今回のファイル観察では、属性、保存形式、暗号化されたデータの存在までを確かめます。ブラウザーの暗号化を解除する作業は必要ありません。

また、開発者ツールに見えるCookie値は、内容を読めなくても認証に使える情報です。記事用のスクリーンショットでは、値を隠し、名前や期限、属性が分かるように切り取ります。

認証Cookieと、サーバー側のSessionは同じものではない

今回のコードには、AddSession や UseSession が出てきません。それでもログイン状態を引き継げます。

この構成では、保護された認証チケットをCookieに持たせ、そのCookieから認証情報を復元しているためです。

一方、サーバー側のSessionは、画面操作の途中データなどを保持するための別の仕組みです。

仕組み主な役割
認証Cookie認証したユーザーの情報を次のリクエストへ引き継ぐ
サーバー側のSession操作途中の値など、アプリで必要なデータを保持する
DBユーザー情報や注文内容などを継続して保存する

Identityのユーザー情報はDBにあります。また、Identityは認証中のユーザーの状態を定期的に確認する場合もあります。Cookieを使うからといって、サーバー側に何も持たず、DBにも一切アクセスしないという意味ではありません。

買い物かごの商品がブラウザーを閉じても残る場合、商品情報はユーザーに結び付けたDBなどに保存されているかもしれません。Cookieに商品一覧が全部入っているとは限りません。

入力中に「セッションが切れました」と出る場合も、認証の有効期限、操作途中のSession、画面固有のトークンなど、どの有効期限に達したのかはアプリ次第です。表示された言葉だけでは、認証Cookieの期限切れと断定できません。

ログアウトすると、次のアクセスはどう変わる?

ログアウト時に認証Cookieを削除し、次のアクセスではログインが必要になる流れ
Cookie削除は、ユーザー登録や業務データの削除とは別です。

ログアウトでは、次のコードを呼び出します。

C#
await _signInManager.SignOutAsync();

レスポンスを通して、ブラウザー側の認証Cookieを削除する処理が行われます。[1]

このブラウザーからCookieが削除された後、認証必須のトップページへアクセスしても、有効な認証情報を復元できなくなります。再びログイン画面へ案内されます。

⑤でも触れたとおり、ブラウザーから削除するだけでは、ほかの場所にコピーされたチケットまで無効になるわけではありません。ユーザーの登録情報や業務データをDBから削除する処理でもありません。

ログアウト後は、開発者ツールでSample.Authがなくなったか確認します。Edgeを終了して保存DBを新しくコピーすれば、ファイル側の変化も追えます。ログアウト前に作ったコピーは当時の記録なので、自動では変わりません。

従来のASP.NET MVCでも、考え方は共通

従来のASP.NET MVCでも、OWINのCookie認証を使って同様の仕組みを実装できます。

ログイン成功時に認証Cookieを発行し、次回以降のアクセスで検証するという基本的な流れは共通です。今回の本文では、その役割をASP.NET Core IdentityとCookie認証のコードで示しました。

通信・画面・保存データを対応づける

開発者ツールのNetworkで「ログを保持」を有効にして、次の順に確認します。起動時に表示されたHTTPSのURLを開いてください。

操作確認する箇所
Cookieのない状態で / を開く/Auth?ReturnUrl=%2F へのリダイレクト
間違ったパスワードを入力するログイン画面のエラー。認証Cookieは発行されない
正しいパスワードでログインするPOSTの応答にSet-CookieとLocationがある
移動先の / を開くリクエストにSample.Authが付く
ページを再読み込みするパスワードの再送なしでユーザー名を表示
ログアウトするCookieを削除するSet-Cookieとログイン画面への移動
再び / を直接開く再度ログインを求められる

偽造防止用など、認証以外のCookieもあります。Cookieが一つでもあるかではなく、今回指定したSample.Authを確認します。Cookieが大きい場合は複数に分割されることもあります。

今回はHTMLを返すMVC画面です。ASP.NET Core 10では、APIとして認識されるエンドポイントへの未認証アクセスは、ログイン画面へのリダイレクトではなく401や403を返す動作があります。画面の例とAPIの例を分けて考えます。

チェックボックスを付けても付けなくても、画面だけでは同じに見えました。だからこそ、フォームの入力からHTTP応答、ブラウザー内のCookie、保存ファイルへと追う意味があります。C#の引数一つが、どの属性として現れるのかを確かめると、「ログイン状態を保持する」という言葉が具体的になります。

認証チケットの保護に使う鍵は、ASP.NET CoreのData Protectionが管理します。JWTのHMAC鍵をこのコードに追加する必要はありません。実際の配置では、アプリ再起動後も鍵を利用できる保存先や、複数サーバー間での鍵共有などを整えます。

次回

次回は、「認証とセッション⑦ ― ログイン済みなのに、不正な操作をされるのはなぜ?」です。

今回はログインとログアウトのPOSTに[ValidateAntiForgeryToken]を付けました。認証Cookieがあるだけでは、操作が利用者の意図どおりとは限りません。ブラウザーのCookie自動送信とCSRFの関係を、具体的な場面から整理します。その後の⑧で、C#のローカル実験に進みます。

参考資料

返信を残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です

CAPTCHA