Web API における操作ごとの制御 (Validation, 認証/権限, Exception 処理 など)


環境 : Visual Studio 2010, ASP.NET Web API (Beta) (ASP.NET MVC 4 Beta)

REST サービス / Web Api の実践

ここでは、応用的なテーマをとりあげます。基本的な構築手順については、「Getting Started with ASP.NET Web Api」 (ASP.NET の場合)、または「REST サービスの作成」 (WCF の場合) を参照してください。

こんにちは。

CLR / H (札幌) にご参加された皆様、トンボ帰り (日帰り) となってしまい、すみませんでした。説明したソース コードのほとんどは、上記のシリーズに記載されていますので ご参照ください。(ゆっくりと、お試しください。)

今回は、デモで紹介した Filter を使ったカスタマイズについて解説しておきます。(あと、Validation についてもちゃんと説明できなかったので . . .)

Web Api (REST サービス) における IoC」では、Web Api のロジックと切り離して、共通のシステム ロジックを組み込む手法と考え方を紹介しました。しかし、Validation (例えば、データなどの妥当性の検証)、権限管理 (例えば、認証された利用者だけが可能な操作、特定のロールの利用者のみが利用可能な操作、など) の処理では、Api によって必要性が異なります。このように、共通化されていても、Api ごとに振る舞い (制御) を変えたい場合は、デモで紹介した Filter (フィルター) のメカニズムが使えます。

なお、説明した通り、ASP.NET MVC でも同様の Filter のメカニズムが提供されていますが、ASP.NET MVC の Filter と、ここで紹介する ASP.NET Web API の Filter は、異なるフレームワークで使用されていますので注意してください。(ASP.NET MVC の Filter をそのまま使用することはできません。System.Web.Mvc 名前空間と System.Web.Http.Filters 名前空間で、使用するインターフェイスなども異なっています。)

 

Custom Filter の基礎

まず、簡単なカスタム フィルター (アクション フィルターのサンプル) を作成し、その動きの基本を見てみましょう。(CLR / H にご参加された方は、ここは読み飛ばしてください。デモと同じことを書きますので . . .) 上記の連載で何度も取り上げた「HTTP ヘッダーをカスタマイズする処理」を、今回は、カスタム フィルターとして作成してみます。

ASP.NET Web API のカスタム フィルターの作成は非常に簡単です。プロジェクト (Visual Studio のプロジェクト) にクラスを追加し (ファイル名を ClientCacheAttribute.cs とします)、このクラスに、下記の通りコードを作成するだけです。(無論、dll として分離しても構いません。)

ActionFilterAttribute 抽象クラスが提供する override メソッドは、OnActionExecuting と OnActionExecuted の 2 つです。OnActionExecuting は Api の実行前、OnActionExecuted は Api の実行後に呼び出されます。そこで、今回は、下記の通り OnActionExecuted を実装し、ここでヘッダーの変更をおこなっています。

. . .

using System.Net.Http;
using System.Web.Http.Filters;
. . .

[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class,
    Inherited = true, AllowMultiple = false)]
public class ClientCacheAttribute : ActionFilterAttribute
{
    private int _duration = 60;

    public int Duration
    {
        get { return this._duration; }
        set { this._duration = value; }
    }

    public override void OnActionExecuted(HttpActionExecutedContext actionExecutedContext)
    {
        HttpResponseMessage result = actionExecutedContext.Result;
        result.Headers.CacheControl =
            new System.Net.Http.Headers.CacheControlHeaderValue() { Private = true };
        result.Content.Headers.Expires = DateTimeOffset.UtcNow.AddMinutes(1);

        base.OnActionExecuted(actionExecutedContext);
    }
}
. . .

この Custom Filter (ClientCache) を使用するには、Web Api に、下記の通り、属性 (Attribute) としてフィルターを設定します。

public class OrderController : ApiController
{
    . . .

    [ClientCache(Duration=30)]
    public HttpResponseMessage Get(int id)
    {
        . . .

なお、アプリケーション内のすべての Web Api に対してこのフィルターを適用したい場合は、Global.asax に、下記の通りフィルターを設定します。

. . .

public static void RegisterRoutes(RouteCollection routes)
{
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

    HttpConfiguration conf = GlobalConfiguration.Configuration;
    conf.Filters.Add(new ClientCacheAttribute()
    {
        Duration = 30
    });
    . . .

 

Validation

さて、こうしたカスタム フィルターでよくあるニーズは、Validation (検証処理) でしょう。
特に、ASP.NET Web API の検証処理では、ASP.NET MVC が持っている Model の Validation メカニズムと同様の処理が実装できます。

例えば、ASP.NET MVC プロジェクト (または ASP.NET Web Api プロジェクト) の Models フォルダーに、下記の通り、モデル データを定義します。(下記は、ASP.NET MVC では よく用いられるモデル定義の方法です。)

. . .
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
. . .

public class OrderModel : IValidatableObject
{
    [Required]
    [StringLength(25, ErrorMessage = "User Name is too log.")]
    [Display(Name = "User Name")]
    public string Name { get; set; }

    [Required]
    [Display(Name = "Delivery Address")]
    public string Address { get; set; }

    [Required]
    [Display(Name = "Postal Code")]
    public string ZipCode { get; set; }

    [Display(Name = "Phone Number")]
    [DataType(DataType.PhoneNumber)]
    public string Telephone { get; set; }

    [Display(Name = "Mail Address")]
    [DataType(DataType.EmailAddress)]
    public string EmailAddress { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (!String.IsNullOrEmpty(Telephone) && !Regex.IsMatch(Telephone, @"^[0-9-]{6,9}$|^[0-9-]{12}$|^\d{1,4}-\d{4}$|^\d{2,5}-\d{1,4}-\d{4}$"))
            yield return new ValidationResult(
                "Telephone number is invalid. (Ex: 03-3333-3333, 0333333333, etc)",
                new[] { "Telephone" });
        if (!String.IsNullOrEmpty(ZipCode) && !Regex.IsMatch(ZipCode, @"^(\d{3}-\d{4}|\d{7})$"))
            yield return new ValidationResult(
                "Zip code is invalid. (Ex: 1234567, 123-4567, etc)",
                new[] { "ZipCode" });
        if (!String.IsNullOrEmpty(EmailAddress) && !Regex.IsMatch(EmailAddress, @"^([0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*@([0-9a-zA-Z][-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,9})$"))
            yield return new ValidationResult(
                "E-mail address is invalid. (Ex: demo@example.com, etc)",
                new[] { "EmailAddress" });
    }
}
. . .

検証用のカスタム フィルターとして、下記の通り、ApiValidationAttribute クラスを追加します。
今度は、OnActionExecuting のほうを実装していますが、下記の通り、ASP.NET MVC コントローラーの実装方法と類似したモデル データの検証をおこなうことができます。

つまり、この手法のいけてる点は、データの型に依存せず、Validation を実装できる点です。(データの種類ごとに Validation の Filter を作成する必要はありません。)

. . .

using System.Net;
using System.Net.Http;
using System.Web.Http.Filters;
using System.Web.Http.ModelBinding;
using System.Json;
. . .

public class ApiValidationAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(System.Web.Http.Controllers.HttpActionContext actionContext)
    {
        ModelStateDictionary modelState = actionContext.ModelState;
        if (!modelState.IsValid)
        {
            foreach (string key in modelState.Keys)
            {
                if (modelState[key].Errors.Count > 0)
                {
                    actionContext.Response =
                        new HttpResponseMessage<JsonValue>(new JsonPrimitive(modelState[key].Errors.First().ErrorMessage),
                            HttpStatusCode.BadRequest);
                    break;
                }
            }
        }

        base.OnActionExecuting(actionContext);
    }
}
. . .

では、動作を確認してみましょう。

まず、上記の ApiValidationAttribute (カスタム フィルター) を、下記の通り Web Api で使用します。

. . .

public class OrderController : ApiController
{
    . . .

    // POST /api/order
    [ApiValidation]
    public HttpResponseMessage<JsonValue> Post(OrderModel model)
    {
        // insert data to database
        ...

        return new HttpResponseMessage<JsonValue>(resultValue, HttpStatusCode.Created);
    }
    . . .

例えば、Address フィールドをブランクにして このメソッド (上記の Post) を呼び出すと、下記の通り結果が返されます。

POST http://localhost:16826/api/order HTTP/1.1
User-Agent: Fiddler
Content-Type: application/json
Accept: application/json
Host: localhost:16826
Content-Length: 142

{
  "Name" : "Tsuyoshi Matsuzaki",
  "ZipCode" : "1080075",
  "Telephone" : "03-1234-5678",
  "EmailAddress" : "tsmatsuz@example.com"
}
HTTP/1.1 400 Bad Request
Server: ASP.NET Development Server/10.0.0.0
Date: Wed, 07 Mar 2012 03:34:12 GMT
X-AspNet-Version: 4.0.30319
Cache-Control: no-cache
Pragma: no-cache
Expires: -1
Content-Type: application/json; charset=utf-8
Connection: Close

"Delivery Address field is required."

また、E-Mail Address 欄にフォーマットの異なるアドレスを指定すると、下記の通り返されます。

POST http://localhost:16826/api/order HTTP/1.1
User-Agent: Fiddler
Content-Type: application/json
Accept: application/json
Host: localhost:16826
Content-Length: 161

{
  "Name" : "Tsuyoshi Matsuzaki",
  "Address" : "Tokyo, Japon",
  "ZipCode" : "1080075",
  "Telephone" : "03-1234-5678",
  "EmailAddress" : "tsmatsuz"
}
HTTP/1.1 400 Bad Request
Server: ASP.NET Development Server/10.0.0.0
Date: Wed, 07 Mar 2012 03:35:51 GMT
X-AspNet-Version: 4.0.30319
Cache-Control: no-cache
Pragma: no-cache
Expires: -1
Content-Type: application/json; charset=utf-8
Connection: Close

"E-mail address is invalid. (Ex: demo@example.com, etc)"

 

権限処理 (Authorization)

2014/05 追記 : Visual Studio 2013 以降の Web API 認証については、「ASP.NET SPA (JavaScript) の Web API 認証 (ASP.NET Identity)」を参照してください。

権限管理も、フィルターによって制御できます。

ASP.NET Web API は、既定では (何も指定しないと)、フォーム認証などによって認証されていないユーザーでも利用可能です。しかし、下記の通り AuthorizeAttribute 属性を指定すると、認証されたユーザーのみに利用を制限できます。(この他に、使用可能なユーザー、ロールなどの制限も可能です。また、クラス全体に AuthorizeAttribute を設定し、メソッドに AllowAnonymousAttribute を設定すると、クラス内の特定の Api のみ匿名アクセスが可能です。)

. . .

public class OrderController : ApiController
{
    . . .

    [Authorize]
    public HttpResponseMessage<JsonValue> Post(OrderModel model)
    {
        ...

権限がない利用者がアクセスすると、下記の通り、結果が返されます。(注意 : なお、権限がない場合は 401 を期待するかもしれませんが、このエラー内容の注意点については、後述に記載しました。)

POST http://localhost:13437/api/order HTTP/1.1
User-Agent: Fiddler
Content-Type: application/json
Accept: application/json
Host: localhost:13437
Content-Length: 175

{
  "Name" : "Tsuyoshi Matsuzaki",
  "Address" : "Tokyo, Japon",
  "ZipCode" : "1080075",
  "Telephone" : "03-1234-5678",
  "EmailAddress" : "tsmatsuz@microsoft.com"
}
HTTP/1.1 302 Found
Server: ASP.NET Development Server/10.0.0.0
Date: Wed, 07 Mar 2012 05:21:37 GMT
X-AspNet-Version: 4.0.30319
Location: /Account/Login?ReturnUrl=%2fapi%2forder
Cache-Control: no-cache
Pragma: no-cache
Expires: -1
Content-Type: text/html; charset=utf-8
Content-Length: 156
Connection: Close

<html><head><title>Object moved</title></head><body>
<h2>Object moved to <a href="/Account/Login?ReturnUrl=%2fapi%2forder">here</a>.</h2>
</body></html>

補足 : 上記の AuthorizeAttribute では、内部で、IIdentity など .NET の標準の Identity / Principal を確認しているため、Windows Azure ACS (クレーム ベース認証) でもちゃんと動きます。(やってませんが、きっと、そのはずです。) また、Basic 認証 (基本認証) などでも、ちゃんと動作します。(Basic 認証でも、認証されたクライアントのみがアクセスできます。)

なお、権限関連のフィルターについては、CLR / H にご参加されていた ASP.NET MVP (ASP.NET MVC じゃなくて、”MVP” ね) の坂本さんが下記に記載されているので、是非参考にしてください。

ブログ : ASP.NET MVC4 WebAPI サイトで認証・承認を行う

http://devadjust.exblog.jp/15525877/

この Authorization のフィルターをカスタマイズすることもできます。
ASP.NET Web API では、目的に応じて下図のいくつかのタイプの Filter に分類されており、今回のカスタマイズでは、IAuthorizationFilter (このインターフェイスを実装した AuthorizationFilterAttribute 抽象クラス) を使用します。

例えば、下記では、認証されていれば権限を与え、認証されてない場合は Basic 認証 (Basic Authentication) を要求して、認証されたクライアントのみが処理を実行できます。(以前記載した「Web Api (REST サービス) で Custom Basic 認証を使用してクラウド (Windows Azure) に配置する」のサンプル コードをそのまま真似しています。)

補足 : 本来は権限処理 (Authorization) を扱うためのものなので、認証処理 (Authentication) については Custom Module (IHttpModule) として作成したほうが良いでしょう。(下記は、Authorization Filter を説明するためのサンプルとして作成しています。)

. . .

using System.Net;
using System.Web.Http;
using System.Web.Http.Filters;
using System.Security.Principal;
using System.Text;
. . .

public class AuthorizeAndRequestBasicAuthAttribute : AuthorizationFilterAttribute
{
    public override void OnAuthorization(System.Web.Http.Controllers.HttpActionContext actionContext)
    {
        // Other authentication check (FBA, etc)
        //IPrincipal userPrincipal = actionContext.ControllerContext.Request.GetUserPrincipal();
        IPrincipal userPrincipal = actionContext.RequestContext.Principal;
        if ((userPrincipal != null) && userPrincipal.Identity.IsAuthenticated)
            return;

        // Basic authentication validation start !
        String userName = null, password = null;
        String authorizationHeader = HttpContext.Current.Request.Headers["Authorization"];
        if (ExtractBasicCredentials(authorizationHeader, ref userName, ref password) &&
            ValidateBasicCredentials(userName, password))
        {
            // set Identity and Principal ... (This time, we skip this code ...)
            //HttpContext.Current.User = new GenericPrincipal(new GenericIdentity("demouser"), null);
            . . .

            return;
        }

        // authentication failer (challenge !)
        //actionContext.Response = actionContext.ControllerContext.Request.CreateResponse(HttpStatusCode.Unauthorized);
        System.Net.Http.HttpResponseMessage res = new System.Net.Http.HttpResponseMessage(HttpStatusCode.Unauthorized);
        res.Headers.Add("WWW-Authenticate", "Basic realm =\"demo\"");
        actionContext.Response = res;
    }

    // Helper 1
    protected bool ExtractBasicCredentials(
        String authorizationHeader, ref String username, ref String password)
    {
        const String HttpBasicSchemeName = "Basic";

        if ((authorizationHeader == null) || (authorizationHeader.Equals(String.Empty)))
            return false;

        String verifiedAuthorizationHeader = authorizationHeader.Trim();

        if (verifiedAuthorizationHeader.IndexOf(HttpBasicSchemeName) != 0)
            return false;

        // Get sub string (eliminated the first "Basic" string) from verifiedAuthorizationHeader
        verifiedAuthorizationHeader =
            verifiedAuthorizationHeader.Substring(
            HttpBasicSchemeName.Length, verifiedAuthorizationHeader.Length - HttpBasicSchemeName.Length).Trim();

        // Decode the base64 encoded string
        byte[] credentialBase64DecodedArray =
            Convert.FromBase64String(verifiedAuthorizationHeader);
        UTF8Encoding encoding = new UTF8Encoding();
        String decodedAuthorizationHeader =
            encoding.GetString(credentialBase64DecodedArray, 0, credentialBase64DecodedArray.Length);

        // Get username and password string
        int separatorPosition = decodedAuthorizationHeader.IndexOf(':');
        if (separatorPosition <= 0)
            return false;
        username = decodedAuthorizationHeader.Substring(0, separatorPosition).Trim();
        password = decodedAuthorizationHeader.Substring(separatorPosition + 1,
            (decodedAuthorizationHeader.Length - separatorPosition - 1)).Trim();
        if (username.Equals(String.Empty) || password.Equals(String.Empty))
            return false;

        return true;
    }

    // Helper 2
    protected bool ValidateBasicCredentials(String userName, String password)
    {
        return (userName == "testuser") && (password == "Password");
    }
}
. . .
public class OrderController : ApiController
{
    . . .

    [AuthorizeAndRequestBasicAuth]
    public HttpResponseMessage Get(int id)
    {
        . . .

このため、認証されていないユーザーでも、下記の通り Basic 認証を使って ajax から呼び出すと、権限を付与できます。ただし、Forms Authentication (フォーム認証) を使用している場合は、下記の補足を参照してください。

. . .

<script type="text/javascript">
    function callWebApi() {
        $.ajax({
            url: '@System.Web.VirtualPathUtility.AppendTrailingSlash(HttpContext.Current.Request.ApplicationPath)api/order/50',
            username: 'testuser',
            password: 'Password',
            complete: function () {
                alert('呼出し完了');
            },
            success: function (result) {
                alert(result.Time);
            }
        });
    };
</script>

<button onclick="javascript:callWebApi()">Test</button>
. . .

補足 : Forms Authentication (FBA) と IAuthorizationFilter の併用 (混合認証) の際の注意

2012/06/04 追記 :
ASP.NET MVC 4 RC 以降、または Visual Studio 2012 RC 以降 (.NET Framework 4.5 RC 以降) を使用すると、Forms Authentication を使って ASP.NET Web API を使用した際、権限がない場合に、フォーム認証によるログイン ページへのリダイレクトはおこなわれず、HttpStatus 401 (Unauthorized) が返ります。(先週金曜にリリースされた RC 版で、対応されています!)
なお、ASP.NET Web API でも、Login ページにリダイレクトさせたい場合は、以下の通り設定します。

<configuration>
  . . .

  <appSettings>
    . . .

    <add key="webapi:EnableSuppressRedirect" value="false"/>
  </appSettings>
</configuration>

実は、上記の Custom Filter (AuthorizeAndRequestBasicAuth) を、フォーム認証 (Forms Authentication) と共に使用しても、うまく動きません。Basic 認証を呼び出しても、最初の呼び出しで、フォーム認証 (FormsAuthenticationModule) によって、常に、Status Code 302 に変換され、ログイン画面にリダイレクトされるためです。

この対処方法については、先ほども記載した 坂本さんのブログ で解説されていますので、是非参考にしてください。坂本さんが記載されているような対処をおこなうと、UI からは Forms Authentication で権限を取得し、Web API をリモートから呼び出す場合には、代わりに Basic 認証を使って呼び出すことができます。(なお、ここでは Custom Filter のサンプルのため上記の通り自作していますが、坂本さんのブログに書かれている通り、NuGet パッケージにある ASP.NET HTTP Authentication Module と併用できるとベストですね。。。)

例えば、下記の IIS モジュールでは、FormsAuthenticationModule と同じタイミング (EndRequest イベント) で、X-Requested-With ヘッダーがある場合にリダイレクトを抑制しています。(つまり、Ajax の場合にも、リダイレクトが抑制されます。また、リモートから Basic 認証を使って呼び出す場合は、X-Requested-With ヘッダーを入れる必要があります。)

public class FormsAuthForCall : IHttpModule
{
    public void Init(HttpApplication context)
    {
        context.EndRequest += new EventHandler(context_EndRequest);
    }

    void context_EndRequest(object sender, EventArgs e)
    {
        HttpApplication application = (HttpApplication)sender;
        HttpContext context = application.Context;
        //// if ajax, then X-Requested-With = XMLHttpRequest . . .
if ((context.Response.StatusCode == 302 || context.Response.StatusCode == 401) &&

            (context.Request.Headers[“X-Requested-With”] != null))
        {
            context.Response.ClearContent();
            context.Response.RedirectLocation = null;
            context.Response.StatusCode = 401;
            context.Response.End();
        }
    }

    public void Dispose()
    {
    }
}

 

今回は、Validation と Authorization を例に、フィルターの扱い方について解説しました。

ここでは説明を省略しますが、上記同様、IExceptionFilter (このインターフェイスを実装した ExceptionFilterAttribute 抽象クラス) を使って、例外処理も細かくカスタマイズできます。

補足 : なお、ASP.NET Web API で例外を発生させる場合は、下記の通り、HttpResponseException クラスが使用できます。

if(. . .)
    throw new HttpResponseException(HttpStatusCode.BadRequest);
    . . .

 

Comments (0)

Skip to main content