しばやん雑記

Azure Serverless とメイドさんが大好きなフリーランスのプログラマーのブログ

ASP.NET Core アプリケーションのログアウト時に認証クッキーを確実に無効化する

ASP.NET Core では一般的にクッキー認証をベースとして使いつつ、その上に Twitter や Facebook などの外部プロバイダーを組み合わせて認証を実装しています。API 向けのように JWT を直接扱う場合以外は、基本はクッキーが有効になっているはずです。

デフォルトでは認証クッキーはステートレスになっていて、必要なクレームは全て暗号化されてクッキーに含まれるようになっています。暗号キーのみ共有すればバックエンドで状態を持つ必要はありませんが、認証クッキーをログアウト時に無効化することは出来ず、基本はクッキーの削除となります。

この辺りの挙動は JWT と同じで明示的に無効化は出来ないので、必要に応じてバックエンド側で何らかの状態を保持してあげる必要があります。というわけで 2 パターンを試しました。

クッキーを使った認証は以下のドキュメントを参照してください。これをベースに拡張していきます。

ログイン・ログアウト用には以下のようなコントローラを適当に用意しました。ログアウト後の挙動だけ確認したいので、ログインするユーザーは固定で生成するようにしています。

public class AccountController : Controller
{
    public IActionResult Login()
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.Name, "shibayan"),
            new Claim(ClaimTypes.Role, "Administrator")
        };

        var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);

        var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);

        var authProperties = new AuthenticationProperties
        {
            IsPersistent = true,
            RedirectUri = Url.Action("Index", "Home")
        };

        return SignIn(claimsPrincipal, authProperties, CookieAuthenticationDefaults.AuthenticationScheme);
    }

    public IActionResult Logout()
    {
        var authProperties = new AuthenticationProperties
        {
            RedirectUri = Url.Action("Index", "Home")
        };

        return SignOut(authProperties, CookieAuthenticationDefaults.AuthenticationScheme);
    }
}

そして実際にログインしているユーザーの名前を返すだけの API を追加して、この API がログアウト後にも呼び出し出来るかで無効化されたかどうかを確認します。

[Authorize]
[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
    [HttpGet]
    public ActionResult Get()
    {
        return Ok(new { name = User.Identity.Name });
    }
}

このままだとログイン後に認証クッキーの値を保存しておいて、ログアウト後に Postman などの別のクライアントから保存しておいたクッキーの値を使ってリクエストを投げると、問題なく API が呼び出せます。

f:id:shiba-yan:20210308031027p:plain

2 パターンのログアウトされた認証クッキーの値を無効化する方法を実際に試していきます。

認証クッキーの有効性をサーバー側で管理する

ASP.NET Core のデフォルトではクッキーに全てのクレームが含まれているので無効化は出来ませんが、リクエスト時にログイン中のユーザーが有効かどうかをチェックするための拡張ポイントは用意されています。

公式ドキュメントにもサーバーサイドでのデータの変更時に、変更されたユーザーを強制的にログアウトさせるサンプルコードが載っています。

同じ考え方でクレームにユニークな ID 発行して、何らかのストレージとクレームに保存しておけば、ログアウトされた時にストレージ側の該当データに無効化フラグ的なものを立てておけば、確実に無効化できます。

今回はそこまでやりませんが、折角ストレージにログイン状態を持たせるので、GitHub などにあるようなアクティブなセッション一覧機能を作成してしまうのが良いと思います。ユーザーが明示的にセッションを無効化出来るようになるので、セキュリティの観点でも良い感じです。

f:id:shiba-yan:20210308025608p:plain

実装に話を戻すと、ユーザーが有効かどうかのイベントは AddCookie のオプション設定時にも追加できますが、そのままだと DI が使いにくいので CookieAuthenticationEvents を継承したクラスを用意して、必要なメソッドをオーバーライドするのが便利です。

話が長くなってきたので今回用意したサンプルコードをさっさと載せておきます。サンプルなのでセッションの一覧はインメモリキャッシュにとりあえず入れています。

// アクティブなセッション一覧を扱う、本来なら RDB や KVS などに入れる
public class ActiveSessionRepository
{
    public ActiveSessionRepository(IMemoryCache memoryCache)
    {
        _memoryCache = memoryCache;
    }

    private readonly IMemoryCache _memoryCache;

    public void Activate(ClaimsPrincipal claimsPrincipal)
    {
        var hash = claimsPrincipal.FindFirstValue(ClaimTypes.SerialNumber);

        if (string.IsNullOrEmpty(hash))
        {
            return;
        }

        _memoryCache.Set(hash, true);
    }

    public void Inactivate(ClaimsPrincipal claimsPrincipal)
    {
        var hash = claimsPrincipal.FindFirstValue(ClaimTypes.SerialNumber);

        if (string.IsNullOrEmpty(hash))
        {
            return;
        }

        _memoryCache.Remove(hash);
    }

    public bool IsActive(ClaimsPrincipal claimsPrincipal)
    {
        var hash = claimsPrincipal.FindFirstValue(ClaimTypes.SerialNumber);

        if (string.IsNullOrEmpty(hash))
        {
            return false;
        }

        return _memoryCache.TryGetValue(hash, out _);
    }
}

public class ActiveSessionCookieAuthenticationEvents : CookieAuthenticationEvents
{
    public ActiveSessionCookieAuthenticationEvents(ActiveSessionRepository activeSessionRepository)
    {
        _activeSessionRepository = activeSessionRepository;
    }

    private readonly ActiveSessionRepository _activeSessionRepository;

    public override Task SigningOut(CookieSigningOutContext context)
    {
        _activeSessionRepository.Inactivate(context.HttpContext.User);

        return Task.CompletedTask;
    }

    public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
    {
        if (!_activeSessionRepository.IsActive(context.Principal))
        {
            context.RejectPrincipal();

            await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        }
    }
}

実装としては大したことはなく、単純な Repository と CookieAuthenticationEvents を継承したクラスです。SigningOutValidatePrincipal をオーバーライドして、ログアウト時にセッションの無効化と毎リクエストでユーザーが有効かどうかを確認しています。

後は StartupCookieAuthenticationEvents を継承したクラスを使うように設定するだけです。インスタンスを直接指定も出来ますが、DI が使えないので EventsType に型情報を渡して内部で解決させます。

public void ConfigureServices(IServiceCollection services)
{
    services.AddScoped<ActiveSessionCookieAuthenticationEvents>();

    services.AddSingleton<ActiveSessionRepository>();

    services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
            .AddCookie(options =>
            {
                options.EventsType = typeof(ActiveSessionCookieAuthenticationEvents);
            });

    services.AddControllersWithViews();
}

最後にログイン時にユニークな ID を生成してクレームに含めつつ、アクティブなセッションとして追加すれば完了です。本来ならログイン時の処理も CookieAuthenticationEvents で実装したかったのですが、実行順序的に無理だったので諦めました。

ユニークな ID を保存するクレームは適当です。カスタム定義を用意したほうが良いかも知れません。

public IActionResult Login()
{
    var claims = new[]
    {
        new Claim(ClaimTypes.Name, "shibayan"),
        new Claim(ClaimTypes.Role, "Administrator"),
        new Claim(ClaimTypes.SerialNumber, Guid.NewGuid().ToString())
    };

    var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);

    var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);

    var authProperties = new AuthenticationProperties
    {
        IsPersistent = true,
        RedirectUri = Url.Action("Index", "Home")
    };

    _activeSessionRepository.Activate(claimsPrincipal);

    return SignIn(claimsPrincipal, authProperties, CookieAuthenticationDefaults.AuthenticationScheme);
}

動作確認としてはログインを行った後に認証クッキーの値を保存しておいて Postman などから実行できることを確認後、ログアウトを行った後は 401 が返るかどうかを確認します。

動画などで出せればわかりやすいのですが、面倒だったので無効化された時のスクショのみ貼ります。

f:id:shiba-yan:20210308030959p:plain

今回の方法の肝は CookieAuthenticationEventsValidatePrincipal をオーバーライドするという 1 点のみです。このメソッドだけ覚えておけば、自由にログイン中ユーザーを無効化できるはずです。

認証情報自体をサーバー側に保存する

ログイン中のセッションを保存するのと考え方は同じなのですが、認証情報のクレーム自体をクッキーではなくサーバー側で保存することで、同じようにログアウト時に確実に無効化することができます。

ログアウト後には認証情報がストレージから削除されてしまえば、絶対にアクセスできないため確実です。こっちの方法は ASP.NET Core に実装されているので、非常に簡単に設定が行えます。

具体的には ITicketStore を実装して使うように設定するだけで完了です。

ASP.NET 時代にはフォーム認証とセッションを組み合わせて、ログイン中ユーザーの情報を保持することが多かったですが、基本的にはそれと同じです。ASP.NET Core では ITicketStore を実装すると、自動的にクッキーにはクレームは含まれなくなります。

ITicketStore のデフォルト実装は存在しなさそうなので、以下のように適当に実装してみました。例によってインメモリのキャッシュに保存するようにしています。

public class InMemorySessionStore : ITicketStore
{
    public InMemorySessionStore(IMemoryCache memoryCache)
    {
        _memoryCache = memoryCache;
    }

    private readonly IMemoryCache _memoryCache;

    public async Task<string> StoreAsync(AuthenticationTicket ticket)
    {
        var key = Guid.NewGuid().ToString();
        var serializedTicket = TicketSerializer.Default.Serialize(ticket);

        _memoryCache.Set(key, serializedTicket);

        return key;
    }

    public async Task RenewAsync(string key, AuthenticationTicket ticket)
    {
        var serializedTicket = TicketSerializer.Default.Serialize(ticket);

        _memoryCache.Set(key, serializedTicket);
    }

    public async Task<AuthenticationTicket> RetrieveAsync(string key)
    {
        if (_memoryCache.TryGetValue<byte[]>(key, out var serializedTicket))
        {
            return TicketSerializer.Default.Deserialize(serializedTicket);
        }

        return null;
    }

    public async Task RemoveAsync(string key)
    {
        _memoryCache.Remove(key);
    }
}

必要なメソッドが少ないので悩むことはないと思いますが AuthenticationTicket をシリアライズする部分は、該当のクラスを知っておかないと悩みそうです。キーは今回も GUID を使うことにしました。

後は Startup で実装した ITicketStore を使うように設定するだけですが、DI と組み合わせる場合には以下のように若干癖のある書き方をしないといけないので注意が必要です。

public void ConfigureServices(IServiceCollection services)
{
    services.AddMemoryCache();

    services.AddSingleton<ITicketStore, InMemorySessionStore>();

    services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
            .AddCookie();

    services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
            .Configure<ITicketStore>((options, store) => options.SessionStore = store);

    services.AddControllersWithViews();
}

これで認証クッキーにはクレームが含まれることが無くなり、ログアウト時には以下のように削除処理が呼び出されるので、以前の認証クッキーは無効となります。

f:id:shiba-yan:20210308032545p:plain

サンプルなのでインメモリキャッシュを使いましたが、以下のドキュメントのように IDistributedCache を使うように実装を少し変えるだけで、クレームを保存するストレージを Redis や Cosmos DB などに変更することができます。

こちらの方法は永続化目的ではなく、純粋に TTL を持ったキャッシュとして扱えば良いので管理は楽になります。単純にログアウト時に確実に無効化したいだけであれば、こちらの方法が非常にお手軽です。

無効化するのにもう少し高度な条件や処理が必要な場合は CookieAuthenticationEvents を実装するのがよさそうでした。上手く使い分けていきたいです。