ぷろじぇくと、みすじら。

AuthenticationHandlerで処理する方法をアクションごとに変更する

Created at:

ASP.NET Core MVCでは認証は基本的にフィルターではなく、認証ハンドラーを認証スキーム名で登録しておき、使用したい認証スキームをAuthorize属性などで指定するという形になっています(デフォルトという指定もできます)。

今回やりたいこととしては「認証機構は基本的に一つで、あるアクションの時だけ認証中に追加で特殊な処理を行いたい」というパターンです。例えばコントローラーに共通のAuthorizeを付けているが、一部特殊なアクションでは追加の処理をしたいというケースです。

難しいところ

そもそも認証ハンドラーはASP.NET Coreの一部であってMVCとは切り離されたものなので、操作可能なものは主にHttpContextとなります。つまりASP.NET Core MVCが認識しているアクションであるとかコントローラーであるとかを取り出すことは難しいため、アクションに付けた属性を引いてくるといったことはできないという悩みがあります。

うまくいかないパターン

そこで最初に考えたのは、二つの認証スキームとして登録してそれぞれで利用する認証スキームを変更するという方法です。

まず次のようなオプションを持つAuthenticationHandlerを用意します。

public class CustomAuthenticationSchemeOptions : AuthenticationSchemeOptions
{
    public bool EnableNanika { get; set; }
}

public class CustomAuthenticationHandler : AuthenticationHandler<CustomAuthenticationSchemeOptions>
{
    public CustomAuthenticationHandler(IOptionsMonitor<CustomAuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
        : base(options, logger, encoder, clock)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (Options.EnableNanika)
        {
            // 一部で必要な特殊な処理...
        }
        return Task.FromResult(AuthenticateResult.Fail("Unauthorized"));
    }

    protected override Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        Response.Headers["WWW-Authenticate"] = "Nanika";
        return base.HandleChallengeAsync(properties);
    }
}

このハンドラーをStartupで二つの認証スキームとして登録します。

services.AddAuthentication()
    .AddScheme<CustomAuthenticationSchemeOptions, CustomAuthenticationHandler>("Custom1", options =>
    {
        options.EnableNanika = false;
    })
    .AddScheme<CustomAuthenticationSchemeOptions, CustomAuthenticationHandler>("Custom2", options =>
    {
        options.EnableNanika = true;
    });

コントローラーにAuthorize属性を付け、その際にコントローラーとアクションで認証スキームを別々に設定します。

[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "Custom1")]
public class ValuesController : Controller
{
    // GET api/values
    [HttpGet]
    [Authorize(AuthenticationSchemes = "Custom2")]
    public IEnumerable<string> Get()
    {
        return new string[] { "value1", "value2" };
    }
}

これでどうかと思ったのですが、この指定で実際に動かすとコントローラーの指定の方が勝つことになります。当然ですがコントローラーにAuthorizeを付けずにアクションごと個別につけると期待通りに動作します。

うまくいかないパターン(2)

認証はフィルターとしても実装できるので(Authorizeも実体はMVCのフィルターで、中で認証ハンドラーを呼び出している)、それをかけてみるのはどうかという風になります。

public class CustomAuthorize : Attribute, IAsyncAuthorizationFilter
{
    public bool EnableNanika { get; set; }

    public Task OnAuthorizationAsync(AuthorizationFilterContext context)
    {
        // ここで認証処理をあれこれやる
        return Task.CompletedTask;
    }
}

この場合にはコントローラーとアクション両方に指定すると二つのフィルターが呼ばれることになる(=二回処理が走る)ので都合が悪い感じになります。それに加えてAuthorize属性と同じようなことを書いてあげる必要があり面倒です。

解決策: フィルターを組み合わせるパターン

認証はフィルターとして実装できるという点を利用して、認証自体は行わずリクエストのデータにフラグを立てておき、認証ハンドラーで取り出して何とかする方法があります。その方法であれば 自前のフィルター(ASP.NET Core MVC) → 自前の認証ハンドラー (ASP.NET Core) という形にできます。

まずアクションにつけるフィルターを作ります。

public class UseCustomAuthenticationWithNanikaAttribute : Attribute, IAsyncAuthorizationFilter, IOrderedFilter
{
    public int Order => int.MinValue;

    public Task OnAuthorizationAsync(AuthorizationFilterContext context)
    {
        context.HttpContext.Features.Set<IUseCustomAuthenticationWithNanikaFeature>(new UseCustomAuthenticationWithNanikaFeature
        {
            Enable = true
        });
        return Task.CompletedTask;
    }
}

public interface IUseCustomAuthenticationWithNanikaFeature
{
    bool Enable { get; set; }
}
public class UseCustomAuthenticationWithNanikaFeature : IUseCustomAuthenticationWithNanikaFeature
{
    public bool Enable { get; set; }
}

このフィルターはOnAuthorizationAsyncでHttpContextのFeatures(リクエスト単位の機能を入れておけるもの)にフラグ情報を持つFeatureを保存します。IOrderedFilterを実装しているのはAuthorize属性より先に来てほしいためです。

次に認証ハンドラーを作ります。このハンドラーの中でFeaturesから先ほどのフィルターでつけていたFeatureを引っ張り出します。

public class CustomAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    public CustomAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
        : base(options, logger, encoder, clock)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var enableNanika = Context.Features.Get<IUseCustomAuthenticationWithNanikaFeature>()?.Enable ?? false;
        if (enableNanika)
        {
            // 一部で必要な特殊な処理...
        }

        return Task.FromResult(AuthenticateResult.Fail("Unauthorized"));
    }

    protected override Task HandleChallengeAsync(AuthenticationProperties properties)
    {
        Response.Headers["WWW-Authenticate"] = "Nanika";
        return base.HandleChallengeAsync(properties);
    }
}

Startupで認証ハンドラーを登録します。この際、AddAuthenticationでデフォルト認証スキームを登録しないように注意します。デフォルトの認証スキームとなるハンドラーは順番が特別なことになるので意図した挙動になりません。

services.AddAuthentication()
    .AddScheme<AuthenticationSchemeOptions, CustomAuthenticationHandler>("Custom", options => { });

そしてコントローラーにはAuthorize属性を、特別な処理をしたいアクションにはUseCustomAuthenticationWithNanika属性でフィルターを付けます。

[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "Custom")]
public class ValuesController : Controller
{
    // GET api/values
    [HttpGet]
    [UseCustomAuthenticationWithNanika]
    public IEnumerable<string> Get()
    {
        return new string[] { "value1", "value2" };
    }

    // GET api/values/5
    [HttpGet("{id}")]
    public string Get(int id)
    {
        return "value";
    }
}

これでアクションに属性を指定するだけで認証時の挙動を少し変更するということが可能になります。