認証とセッション

 認証とセッション⑧

 URLを開くだけで、目的の画面へ入れる仕組み

リンクを開いただけでログインできる。これは、ログイン画面を飛ばしているだけでしょうか。それとも、URLの中に本人確認のための情報が入っているのでしょうか。

今回は、⑥で作ったCookieLoginSampleに、小さな学習用の入口を追加します。ユーザー情報と時刻情報を暗号化・改ざん保護してURLに載せ、受信側で検証・復号します。有効期限内であることなどを確認できたら、ASP.NET Core Identityで認証Cookieを発行し、⑥のログイン後の画面へ進みます。

⑥のサンプルに、URLからログインする入口を追加します。

普通のURLと、ログイン用のURLは何が違うのか

同じURLでもログイン状態に応じて目的の画面への進み方が変わる
ReturnUrlは戻り先を引き継ぐ情報で、認証の代わりではありません。

普通の画面URLが示すのは行き先です。認証が必要な画面なら、ブラウザーから送られた認証Cookieなどを確認して表示します。未ログインならログイン画面へ案内し、ReturnUrlで元の行き先を引き継ぎます。

今回追加するURLは、それに加えてログインの検証に使うトークンを運びます。まだ認証Cookieがないブラウザーでも、トークンが有効ならサーバーがCookieを発行します。

比較普通の画面URL今回のログイン用URL
URLが運ぶ情報行き先や表示条件ユーザー情報と期限を保護したトークン
未ログインで開くログイン画面へ案内トークンの有効性を検証
認証Cookieの発行フォームでログインした後トークン検証に成功した後
その後のアクセス認証Cookieで状態を引き継ぐ認証Cookieで状態を引き継ぐ

つまり、URLは行き先だけを表す場合もあれば、認証の根拠となる秘密情報を運ぶ場合もあります。ReturnUrlそのものは秘密情報ではありません。

実画面で見る、戻り先の引き継ぎ

次の3枚は、普通の画面URLである /Work/List?sort=name を使って確認したものです。このURL自体にログイン用の秘密情報は入っていません。以下で追加する期限付きトークンによるログインとは、分けて見てください。

未認証で作業一覧を開き、ReturnUrl付きのログイン画面へ案内された状態
未認証:目的のパスをReturnUrlに付けてログイン画面へ。
認証後にWork/List?sort=nameを表示した状態
認証後:目的の作業一覧を名前順で表示。

ログイン画面のアドレスにある ReturnUrl=%2FWork%2FList%3Fsort%3Dname は、戻り先の /Work/List?sort=name をURLエンコードしたものです。

Elementsでフォーム内のhidden項目ReturnUrlとPOST先Authを確認
POST送信前のフォーム:GETで受け取った戻り先が、非表示項目ReturnUrlに入っている。画像をクリックすると拡大できます。

画像の input type="hidden" には、name="ReturnUrl" と value="/Work/List?sort=name" が設定されています。画面の入力欄には表示されませんが、HTMLのフォーム内には存在します。

  1. ログイン画面へ案内する
    開こうとしたページのURLを、ログイン画面のURLに ReturnUrl として付けます。
  2. ログイン画面を表示する
    サーバーはGETで受け取った ReturnUrl を、フォームの非表示項目に入れます。
  3. ログインボタンを押す
    ユーザー名・パスワードと一緒に、非表示項目の ReturnUrl もPOSTで送ります。
  4. ログインに成功する
    サーバーが ReturnUrl を戻り先として使ってよいか確認し、そのURLへ移動させます。

ログイン操作の間も「最初に開こうとしたページ」を引き継ぐための仕組みです。 GETの内容が自動的にPOSTへ引き継がれるのではなく、フォームに埋め込むことで引き継いでいます。ReturnUrlは移動先の指定であり、それだけでログインできる情報ではありません。

⑥に追加するのは、コントローラーと画面の二つ

今回は、ユーザー登録やDB、通常のログイン・ログアウト処理を作り直しません。⑥で動作確認したプロジェクトをコピーして、学習用の変更を加えます。

ファイル今回の作業
Controllers/LinkController.cs新規追加。リンクの発行・検証・サインインを担当
Views/Link/Index.cshtml新規追加。自分のリンクを発行する画面
Program.cs⑥のIdentity・Cookie・MVC設定をそのまま使用
AuthController、既存のビュー、DB⑥のものをそのまま使用

追加のNuGetパッケージとDBマイグレーションは不要です。⑥で登録したdemoユーザーを使います。以前に作業一覧のWorkControllerを追加していても構いませんが、今回の行き先は既存のHome/Indexに固定するため、必須ではありません。

機能を小さく保つため、リンクの記録はアプリのメモリに置きます。発行できるのはログイン済みの自分用だけで、期限は2分、利用は一回限りです。新しいリンクを発行すると同じユーザーの古いリンクは無効になり、アプリを再起動しても記録は消えます。

今回はローカルで仕組みを見るための実装です。Development環境以外では入口が404を返すようにします。

ユーザー情報と期限を、保護したひとまとまりにする

今回のトークンには、ユーザーID、発行時刻、ランダムな識別子、発行時のSecurityStampを含めます。SecurityStampは、パスワード変更などによるユーザー状態の変化を検出するために比較する値です。ユーザーのパスワードそのものは入れません。

有効期限は発行時刻から2分後です。この期限も含め、ASP.NET CoreのData Protectionで保護します。URLのクエリにユーザーIDや時刻を別々に平文で並べるのではなく、保護済みの文字列をtokenという一つの項目で渡します。

  1. ユーザーID・発行時刻・ランダムな識別子などをまとめる。
  2. 有効期限を指定し、暗号化・改ざん保護する。
  3. 保護済みのトークンをURLに載せる。
  4. 受信側で改ざんの有無と期限を検証し、内容を復号する。
  5. 未使用であることとユーザーの状態を確認し、Identityでログインする。

発行時刻をランダムな位置に埋め込むこと自体を、保護の仕組みにはしません。時刻とランダム値は役割を分けて持たせ、内容全体を暗号技術で保護します。ランダムな識別子は、同じユーザーに発行した別々のリンクを区別し、一回限りの利用を管理するために使います。暗号化に必要なIVなどはData Protection側に任せます。

AESと改ざん検出、期限の確認を標準機能に任せる

Data Protectionの既定の構成では、暗号化にAES-256-CBC、改ざん検出にHMACSHA256を使います。単にAESで暗号化するだけでなく、内容や期限を書き換えたトークンも受け付けないことが重要です。

今回は、その期限付きのAPIであるITimeLimitedDataProtectorを使います。Protectに有効期限を渡し、Unprotectで保護の検証・復号と期限の確認を行います。期限切れならCryptographicExceptionになります。発行時刻からの差を自前で計算する代わりに、「発行時刻+2分」という期限を標準機能で扱います。

発行と検証は同じアプリのサーバー時刻を使います。クライアント側の時計は判定に使いません。複数サーバーで運用するなら、時刻とData Protectionの鍵を共有・管理する設計が別途必要です。

暗号鍵をURLへ入れたり、コード内に固定文字列で書いたりはしません。⑥の環境で登録されているData Protectionを利用します。鍵の保存・保護はホスト環境によって変わるため、実運用では鍵を失わないことと、読める実行主体を限定することも確認します。

リンクを発行し、検証してCookieへつなぐ

次の内容でControllers/LinkController.csを追加します。

C#
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;

namespace CookieLoginSample.Controllers;

[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public class LinkController : Controller
{
    private sealed record Payload(string UserId, string Nonce,
        DateTimeOffset IssuedAt, string? SecurityStamp);
    private sealed record PendingLink(string Nonce, DateTimeOffset Expires);

    private static readonly object Gate = new();
    private static readonly Dictionary<string, PendingLink> Pending = new();
    private static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(2);

    private readonly UserManager<IdentityUser> _users;
    private readonly SignInManager<IdentityUser> _signIn;
    private readonly IWebHostEnvironment _environment;
    private readonly ITimeLimitedDataProtector _protector;

    public LinkController(UserManager<IdentityUser> users,
        SignInManager<IdentityUser> signIn, IWebHostEnvironment environment,
        IDataProtectionProvider protection)
    {
        _users = users;
        _signIn = signIn;
        _environment = environment;
        _protector = protection.CreateProtector("CookieLoginSample.LoginLink.v1")
            .ToTimeLimitedDataProtector();
    }

    [HttpGet]
    [Authorize]
    public IActionResult Index()
    {
        if (!_environment.IsDevelopment()) return NotFound();
        Response.Headers["Referrer-Policy"] = "no-referrer";
        return View();
    }

    [HttpPost]
    [Authorize]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Create()
    {
        if (!_environment.IsDevelopment()) return NotFound();
        Response.Headers["Referrer-Policy"] = "no-referrer";

        // 現在ログインしている自分のリンクだけを発行する。
        var user = await _users.GetUserAsync(User);
        if (user is null || !await CanUseLinkAsync(user)) return Forbid();

        var now = DateTimeOffset.UtcNow;
        var expires = now.Add(Lifetime);
        string nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
        var payload = new Payload(user.Id, nonce, now,
            await _users.GetSecurityStampAsync(user));

        // 内容と期限を暗号化・改ざん保護する。
        string token = _protector.Protect(JsonSerializer.Serialize(payload), expires);

        lock (Gate)
        {
            foreach (var id in Pending.Where(x => x.Value.Expires <= now)
                .Select(x => x.Key).ToArray())
                Pending.Remove(id);

            if (!Pending.ContainsKey(user.Id) && Pending.Count >= 128)
                return StatusCode(503);

            // 再発行時は古いリンクの識別子を置き換える。
            Pending[user.Id] = new PendingLink(nonce, expires);
        }

        // ユーザーIDなどは外へ出さず、保護済み文字列だけをURLに載せる。
        ViewBag.LoginLink = Url.Action(nameof(Open), "Link", new { token });
        return View("Index");
    }

    [HttpGet]
    [AllowAnonymous]
    public async Task<IActionResult> Open(string? token)
    {
        if (!_environment.IsDevelopment()) return NotFound();
        Response.Headers["Referrer-Policy"] = "no-referrer";
        if (string.IsNullOrEmpty(token) || token.Length > 8192)
            return BadRequest("リンクは無効です。");

        Payload? payload;
        DateTimeOffset expires;
        try
        {
            // 改ざん・期限切れなどの場合はCryptographicExceptionになる。
            string json = _protector.Unprotect(token, out expires);
            payload = JsonSerializer.Deserialize<Payload>(json);
        }
        catch (Exception ex) when (ex is CryptographicException or JsonException)
        {
            return BadRequest("リンクは無効、または期限切れです。");
        }

        if (payload is null || string.IsNullOrEmpty(payload.UserId) ||
            string.IsNullOrEmpty(payload.Nonce))
            return BadRequest("リンクは無効です。");

        // 別アカウントでログイン中なら勝手に切り替えない。
        if (User.Identity?.IsAuthenticated == true &&
            _users.GetUserId(User) != payload.UserId)
            return BadRequest("いったんログアウトしてください。");

        lock (Gate)
        {
            // 期限の再確認と一回限りの消費を、同じロック内で行う。
            if (expires <= DateTimeOffset.UtcNow ||
                !Pending.TryGetValue(payload.UserId, out var pending) ||
                pending.Expires <= DateTimeOffset.UtcNow ||
                pending.Nonce != payload.Nonce)
                return BadRequest("リンクは無効、期限切れ、または使用済みです。");

            Pending.Remove(payload.UserId);
        }

        var user = await _users.FindByIdAsync(payload.UserId);
        if (user is null || !await CanUseLinkAsync(user) ||
            payload.SecurityStamp != await _users.GetSecurityStampAsync(user))
            return BadRequest("このリンクではログインできません。");

        // 検証を終えてからIdentityで認証Cookieを発行する。
        await _signIn.SignInAsync(user, isPersistent: false);
        return RedirectToAction("Index", "Home");
    }

    private async Task<bool> CanUseLinkAsync(IdentityUser user)
    {
        return await _signIn.CanSignInAsync(user)
            && !await _users.IsLockedOutAsync(user)
            && !await _users.GetTwoFactorEnabledAsync(user);
    }
}

処理の入口は三つです。Indexは発行画面の表示、Createはリンクの発行、Openはリンクを開いたときの検証を担当します。

CreateはAuthorizeでログインを要求し、GetUserAsync(User)で現在のユーザーを取得します。別のユーザーIDを入力して、その人のログイン用リンクを自由に発行する画面にはしていません。

発行時にはRandomNumberGeneratorで識別子を生成し、ユーザー情報と発行時刻をJSONへまとめます。その内容と有効期限をProtectで保護し、保護済み文字列だけをURLへ載せます。

CreateProtectorに指定したCookieLoginSample.LoginLink.v1は、このトークンを他の用途と区別するための目的文字列です。秘密鍵ではありません。同じ鍵を利用していても、用途を分けて扱うために指定します。

OpenではUnprotectを呼び、保護を検証して内容を取り出します。ここで期限切れや改ざんを拒否した後、メモリに残した識別子と照合して、未使用かを確認します。確認と削除は同じlockの中で行うため、同じリンクが同時に使われても消費できるのは一度だけです。

暗号化・期限の検証と、一回限りの管理は別の仕組みです。Data Protectionだけで使用済み状態を管理してくれるわけではありません。メモリの記録はそのために残しています。消費した後にユーザー状態の確認が失敗した場合も、そのリンクは再利用できません。

最後に呼ぶのが、⑥と同じASP.NET Core IdentityのSignInManagerです。

C#
await _signIn.SignInAsync(user, isPersistent: false);
return RedirectToAction("Index", "Home");

SignInAsyncは、渡されたユーザーの認証Cookieを発行するための処理です。この呼び出しだけでパスワードやURLのトークンを検証してくれるわけではありません。そのため、この例では先にトークン、期限、ユーザーの状態を確認しています。

CanSignInAsyncだけでロックアウトや二要素認証まで全部確認できるわけでもありません。このサンプルではロックアウト中のユーザーと二要素認証を有効にしたユーザーを拒否し、追加認証が必要なアカウントをそのまま通さないようにしています。SecurityStampも発行時と比較し、変更されていれば拒否します。

別のアカウントでログイン中の場合も、勝手にユーザーを切り替えず、いったんログアウトするよう案内します。

リンクを作る画面を追加する

ViewsフォルダーにLinkフォルダーを作り、その中にIndex.cshtmlを追加します。

Razor
@{
    ViewData["Title"] = "ログイン用リンクの実験";
}

<h1>ログイン用リンクの実験</h1>
<p>自分のアカウント用に、2分間・一回限りのリンクを発行します。</p>

<form asp-controller="Link" asp-action="Create" method="post">
    <button type="submit">リンクを発行する</button>
</form>

@if (ViewBag.LoginLink is string link)
{
    <p>下のリンクを右クリックし、リンクのアドレスをコピーしてください。</p>
    <p>今はクリックせず、ログアウト後にアドレス欄へ貼り付けます。</p>
    <p><a href="@link" rel="noreferrer">今回のログイン用リンク</a></p>
}

⑥のViews/_ViewImports.cshtmlで有効になっているForm Tag Helperを使います。POSTフォームへ偽造防止用のhidden項目が生成され、Create側のValidateAntiForgeryTokenで検証します。⑦で説明した「生成する側」と「検証する側」の組み合わせです。

リンクの発行はPOSTですが、発行したリンクを開くOpenは、この実験ではGETです。ログイン用リンクそのものを、通常のフォーム用偽造防止トークンと混同しないでください。

実際に、ログアウト後のブラウザーで開く

まず⑥のプロジェクトをDevelopment環境で起動します。HTTPSのポート番号は、自分の環境の値を使います。ここでは7105を例にします。

  1. 通常のログイン画面でdemoユーザーとしてログインする。
  2. https://localhost:7105/Link を開く。
  3. 「リンクを発行する」を押す。
  4. 表示されたリンクをクリックせず、右クリックでリンクのアドレスをコピーする。
  5. 別タブでホーム画面を開き、⑥のログアウトボタンでログアウトする。
  6. 発行から2分以内に、コピーしたURLをアドレス欄に貼り付けて開く。
  7. パスワードを再入力せず、ホーム画面にdemoユーザーとして表示されるか確認する。

アプリは途中で停止しません。リンクの記録はメモリ上にあるため、停止・再起動すると無効になります。

URLは、例えば次の形です。ユーザーIDや時刻は保護された内容に含まれ、URLを眺めただけでは読み取れません。下のtokenの値は説明用の表記です。

Plaintext
https://localhost:7105/Link/Open?token=暗号化と改ざん保護を施した文字列

同じURLをもう一度開くと、使用済みとして拒否されます。期限切れは新しくリンクを発行し、2分を過ぎてから開いて確認します。トークンの一文字を書き換えた場合も拒否されることを確認できます。

確認する条件このコードで期待する動作
有効なリンクを期限内に初めて開くCookieを発行し、ホームへ移動
同じリンクを再利用する使用済みとして拒否
未使用でも2分を過ぎて開く期限切れとして拒否
トークンを書き換える一致しないため拒否
発行後にアプリを再起動する記録がなくなり拒否

実験では、リンクの発行画面、ログイン後の画面、リンクが拒否された画面を確認しました。以下は実際の画面です。拒否画面のメッセージは複数の条件で共通のため、この画像だけで使用済み・期限切れ・改ざんのどれかを特定することはできません。上の表は各条件を個別に確認するための一覧です。

リンク発行前:自分のアカウント用に、2分間・一回限りのリンクを作る。
リンク発行前:自分のアカウント用に、2分間・一回限りのリンクを作る。
リンク発行後:アドレスをコピーし、ログアウト後に開く。
リンク発行後:アドレスをコピーし、ログアウト後に開く。
ログイン後の画面:demoユーザーとしてホーム画面を表示。
ログイン後の画面:demoユーザーとしてホーム画面を表示。
拒否された画面:「リンクは無効、または期限切れです。」と表示。
拒否された画面:「リンクは無効、または期限切れです。」と表示。

開発者ツールでは、Cookieの発行を見る

⑥で使ったブラウザーの開発者ツールでも観察できます。Networkの記録を保持する設定にしてからリンクを開くと、Openへの要求、ホームへの移動、その後の要求を追えます。

成功時はOpenの応答で認証Cookieを設定し、ホームへの移動を指示します。その後のホームへの要求では、ブラウザーがCookieを送ります。ApplicationのCookiesでは、⑥と同じSample.Authを確認します。

Openの応答ヘッダー:Set-CookieでSample.Authを発行し、Location: /で移動先を指定する。
Openの応答ヘッダー:Set-CookieでSample.Authを発行し、Location: /で移動先を指定する。

Openの302応答には、Set-Cookie: Sample.Auth=… と Location: / が入っています。Cookieを保存させる指示と、ホーム画面へ移動させる指示は、一つの応答で返されます。

移動先localhostへの要求ヘッダー:CookieにSample.Authが含まれている。
移動先localhostへの要求ヘッダー:CookieにSample.Authが含まれている。

次のホームへの要求を見ると、Cookie ヘッダーに Sample.Auth=… が含まれています。ブラウザーが保存した認証Cookieを送り返し、サーバーがログイン状態を確認する流れです。画像には偽造防止Cookieも並んでいますが、ここで認証状態の確認に使うのはSample.Authです。

今回のSignInAsyncにはisPersistent: falseを指定しているので、ブラウザー側ではセッションCookieになります。ただし、認証チケットの期限まで無期限になるわけではありません。Cookieと認証チケットの扱いは⑥の設定を引き継ぎます。

以後の画面アクセスでは、毎回元のログイン用トークンを送り直す必要はありません。入口でトークンを検証した後は、認証Cookieへ役割を引き継いでいるのです。

行き先の指定と、閲覧権限は分けて考える

戻り先のローカルURL確認と、移動先の認証・認可の違い
行き先として許されることと、そのユーザーが閲覧できることは別です。

今回のコードは、目的の画面をHome/Indexに固定しています。URLから自由な転送先を受け取らないので、まずはトークン検証とCookie発行に集中できます。

将来、行き先を選べるようにする場合は、許可する画面をサーバー側で管理し、発行したトークンにその目的を結び付けます。ユーザーや行き先をURLで差し替えるだけで、違う権限を得られる構成にはしません。

認証に成功していても、その画面や個別データを見てよいかという認可は別です。ReturnUrlのローカルURL確認も、データの閲覧権限を保証するものではありません。

秘密情報付きURLは、誰に渡すかも大切

普通の画面リンクと一時的な認証情報を含むメールのログイン用リンクの比較
普通のリンクは行き先を指定し、今回のログイン用リンクは認証に使う一時トークンも運びます。

今回のトークンは、ユーザー情報と期限を保護した文字列です。JWTでも、ユーザーのパスワードを暗号化した値でもありません。受信側は中身を検証・復号でき、別に保持した識別子の記録で再利用を防ぎます。

暗号化しても、有効なURLを丸ごと持つ人は、そのリンクの対象ユーザーとしてログインできるため、リンクの配送先や共有の仕方も認証の一部になります。学習用の今回は、自分で発行して自分で使います。メール送信や第三者への配布は実装していません。

そのまま公開サービスにしないための境界

暗号化は中身を隠し、改ざん保護は書き換えを拒否します。しかし、有効なトークンをそのままコピーして使うことは別の問題です。短い期限や一回限りの制限だけで、公開サービス向けの設計が完成するわけではありません。リンクを開いた人の意思確認、意図しないアカウントへのログイン誘導、メールの安全確認機能による先読み、アクセス頻度の制限、ログに秘密情報を残さない運用なども検討が必要です。

特にこの例はGETでトークンを消費してサインインします。クリックや先読みによって認証状態が変わるため、本番では確認画面とPOSTでの確定、発行開始時のブラウザーとの結び付けなど、用途に応じた設計を行います。発行フォームのCSRF対策だけで、リンク利用側の問題まで解決するわけではありません。

NoStoreやReferrer-Policyも設定していますが、ブラウザー履歴やサーバーログから秘密を自動的に消す機能ではありません。実際のトークンを含むURLは、記事のスクリーンショットやログに掲載しないようにします。

入口が変わっても、ログイン後の仕組みは同じ

段階⑥の通常ログイン今回のリンクログイン
最初に提示するものユーザー名とパスワードURLの一時トークン
サーバーの検証パスワードなどの検証トークンの保護・期限・利用状態などの検証
成功後Identityで認証Cookieを発行Identityで認証Cookieを発行
次のアクセスCookieでログイン状態を確認Cookieでログイン状態を確認

ログイン画面が表示されなかったからといって、認証処理がなくなったわけではありません。入口で何を提示するかが変わり、その後は⑥で見たCookie認証へつながります。

次回はWeb APIへ進みます。ブラウザーがCookieを送る場合に加え、アプリがAuthorizationヘッダーへBearerトークンを付ける場合を見ながら、何を提示してアクセスを認めてもらうのかを整理します。

参考資料

返信を残す

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

CAPTCHA