noxi雑記

.NET、Angularまわりの小ネタブログ

ASP.NET CoreのSPAでIRouteConstraintを使用して認証を実装する

ASP.NETMVCルーティングはコントローラー名やアクションメソッド名に応じてURLルーティングがなされます。ASP.NET CoreのSPA Angularテンプレートは、ASP.NET Core 2.0時代はMVCルーティング上にAngularアプリが存在していましたが、ASP.NET Core 2.1からはAngular CLIを裏で動かすものに変化しました。Angular CLIASP.NET CoreのMVCルーティング上には存在せず、Authorizeで簡単に認証を実装することができません。そこで IRouteConstraint を使用してMVCルーティングをカスタマイズすることで、Angularに対して認証を手軽に実装します。

TL;DR

Microsoft.AspNetCore.Routing.IRouteConstraint を実装することで、HttpContextに応じてルーティングのON/OFF実装をすることができます。

ASP.NET CoreのSPAでIRouteConstraintを使用して認証を実装する

検証環境

IRouteConstraintとは何か

IRouteConstraint とはMVCルーティング設定の際に constraint 引数に対して使用するインターフェースです。 bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) メソッドを実装し、HttpContext等の状態に応じてルーティングの可否を設定できます。

ASP.NET Core 2.1のAngularテンプレートのStartup

ASP.NET Core 2.1のAngularテンプレートの全体構成がどうなっているかについての解説はそのうち記事にしたいなと思っていますが、今回はStartupのMVCルーティングおよびSpaServicesに関する部分だけ取り上げます。Startupの該当部分は、テンプレート上では次のようになっています。

if (env.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseSpaStaticFiles();

app.UseMvc(routes =>
{
    routes.MapRoute(
        name: "default",
        template: "{controller}/{action=Index}/{id?}");
});

app.UseSpa(spa =>
{
    // To learn more about options for serving an Angular SPA from ASP.NET Core,
    // see https://go.microsoft.com/fwlink/?linkid=864501

    spa.Options.SourcePath = "ClientApp";

    if (env.IsDevelopment())
    {
        spa.UseAngularCliServer(npmScript: "start");
    }
});

※参考:ASP.NET Core 2.0の該当部分

if (env.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
    app.UseWebpackDevMiddleware(new WebpackDevMiddlewareOptions
    {
        HotModuleReplacement = true
    });
}
else
{
    app.UseExceptionHandler("/Home/Error");
}

app.UseStaticFiles();

app.UseMvc(routes =>
{
    routes.MapRoute(
        name: "default",
        template: "{controller=Home}/{action=Index}/{id?}");

    routes.MapSpaFallbackRoute(
        name: "spa-fallback",
        defaults: new { controller = "Home", action = "Index" });
});

該当部分といいつつ2.0と2.1それぞれのConfigureメソッドの全ての中身なのですが、これを見るだけでも、大幅に変更されていることが分かります。後に貼り付けた2.0の方はAngular SPAがMVCルーティング設定の中で MapSpaFallbackRoute でHomeコントローラーのIndexアクションメソッドへルーティングされています。先に貼り付けた2.1ではMVCルーティング設定にデフォルトが存在せず指定したコントローラーとアクションメソッドのペアが存在する場合のみMVCルーティングされ、それ以外は次のSPAミドルウェアが動作します。このSPAミドルウェア、開発環境ではAngular CLIserve を裏で動かしリダイレクト、本番環境ではコンパイル済のHTMLやJSを返す、という動作になっており、MVCミドルウェアを素通りするためMVCの認証が動作しません。

IRouteConstraintを実装してHttpContextをチェックする

ASP.NET Core 2.1のSPAに対して認証を実装するには、先に記したSPAミドルウェアの実装を見るに、未認証の場合はMVCミドルウェアでハンドリングし認証を行い、認証済みの場合はMVCミドルウェアを素通りさせれば良いと思われます。IRouteConstraintを使用するとMVCルーティングの合致条件にHttpContextを含めることができます。まずはHttpContextに対して汎用に条件を設定出来る HttpContextConstraint クラスを実装します。

public class HttpContextConstraint : IRouteConstraint
{
    private readonly Func<HttpContext, bool> _constraint;

    public HttpContextConstraint(Func<HttpContext, bool> constraint)
    {
        _constraint = constraint ?? throw new ArgumentNullException(nameof(constraint));
    }

    public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
    {
        return _constraint(httpContext);
    }
}

コンストラクタでHttpContextに対して条件を指定できるConstraintを実装しました。このFuncがtrueを返せばこのConstraintを設定したMVCルーティングが動作し、falseを返せば素通りします。次に、実際にこれをSPAの認証用途で指定します。

app.UseMvc(routes =>
{
    routes.MapRoute(
        name: "default",
        template: "{controller}/{action=Index}/{id?}");

    routes.MapRoute(
        name: "spa-authorize",
        template: "{*url}",
        defaults: new { controller = "Home", action = "Index" },
        constraints: new
        {
            authorize = new HttpContextConstraint(c => !c.User.Identity.IsAuthenticated)
        });
});

これでアクセスしたユーザーが未認証の状態ではHomeコントローラーのIndexアクションメソッドが利用され、認証済みの場合はMVCミドルウェアを素通りする実装ができました。

おわりに

最初は Route クラスを継承して RouteAsync をゴニョゴニョしようと思っていたのですが、途中でConstraintでもできることに気付いて変更しました。RouteAsyncメソッドは非同期処理メソッドですので、何らかの条件を非同期で実装する必要がある場合はRouteを継承したクラスを作成するのも手だと思います。