WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

ASP.NET Web API の認証

by WebSurfer 2018年12月17日 15:40

Visual Studio 2013 / 2015 の Web API テンプレートで、認証を「個別のユーザーアカウント」として自動生成させた Web API アプリの認証は、デフォルトで、トークンを要求ヘッダに含めてサーバーに送信するトークンベースになります。(MVC テンプレートは不明。Core は全く不明)

ベアラトークン

上の画像は Web API に要求をかけたときの要求ヘッダを Fiddler で表示したものです。赤枠で示した部分が認証に使われるトークンです。(画像には認証クッキーも含まれていますが、その理由等については後述します)

(注:クッキーではなくトークンを使う理由はセキュリティ (CSRF) 対策だそうです。MVC アプリではビューに Html ヘルパーの AntiForgeryToken メソッドを、アクションメソッドに [ValidateAntiForgeryToken] 属性を付与して CSRF 対策ができますが、Web API ではそういう手段が使えませんから)

テンプレートで自動生成されたそのままの状態でアプリを実行してトークンが送られるわけではないです。トークンを送って認証が通るようにする手順と注意事項を以下に書いておきます。

トークンによる認証の仕組みについては Microsoft の文書 Secure a Web API with Individual Accounts and Local Login in ASP.NET Web API 2.2 に詳しく書いてありますので、それを読むことをお勧めします。

その記事からリンクが張ってある GitHub のサイトから完全なサンプルが入手できます。それを試してもらってもいいのですが、そのサンプルには SSL 関係や knockout.js など Web API の認証とは直接関係のない部分もあって分かり難いところがあるかもしれません。(注:運用上は SSL の実装は必須です。knockout.js は必須ではありませんが)

なので、以下に Visual Studio 2015 の Web API テンプレート作成したアプリに、追加で何を実装すればトークンを入手でき、そしてトークンをどのように送信すれば認証が通るかだけを書いておきます。

事前に、ASP.NET Identity がユーザー情報を取得する LocalDB に email と password を登録し有効なユーザーアカウントを作成しておく必要があります。(その手順は本題とは直接関係ないので割愛します。手抜きでスミマセン)

トークンの入手は、ビューに以下のようなスクリプトを追加し、jQuery ajax を使って登録済みの email と password をトークンエンドポイント /Token に POST してやります。email と password が有効であれば、応答の JSON 文字列にトークンが含まれて戻ってきます。そのトークンを sessionStorage に保存します。

var tokenKey = 'accessToken';

function getToken() {
    var email = document.getElementById("email").value;
    var password = document.getElementById("password").value;

    var loginData = {
        grant_type: 'password',
        username: email,
        password: password
    };

    $.ajax({
        type: "POST",
        url: "/Token",
        data: loginData,
        success: function (data) {
          sessionStorage.setItem(tokenKey, data.access_token);
        },
        error: function (jqXHR, textStatus, errorThrown) {
            // ・・・中略・・・
        }
    });
}

トークンの送信は、上の getToken メソッドで sessionStorage に保存したトークンを取得し、以下のように要求ヘッダの Authorization に設定して送信してやります。それで認証が通って期待した応答が返ってきます。

function apiHeroesGet() {
    var token = sessionStorage.getItem(tokenKey);
    var headers = {};
    if (token) {
        headers.Authorization = 'Bearer ' + token;
    }

    $.ajax({
        type: "GET",
        url: "/api/values",
        headers: headers,
        success: function (data, textStatus, jqXHR) {
            // ・・・中略・・・
        },
        error: function (jqXHR, textStatus, errorThrown) {
            // ・・・中略・・・
        }
    });
}

主な話は以上ですが、追加で 2, 3 気になったことを書いておきます。

(1) トークンエンドポイント /Token

上のスクリプトの getToken メソッドの email と password の送信先 /Token は、Startup.Auth.cs の ConfigureAuth メソッドで OAuthAuthorizationServerOptions クラスを初期化するときに TokenEndpointPath プロパティに設定されます。

自動生成されたコードの中のアクションメソッドだと思って探したけれど、見つからないので焦ったというのは内緒です。(笑)

(2) 認証クッキーの発行

上の画像で認証クッキーも発行されていますが、それは上のスクリプトの getToken メソッドでトークンを発行する際に同時に発行されます。

デフォルトで、Startup.Auth.cs の ConfigureAuth メソッドには app.UseCookieAuthentication(...) メソッドが含まれていることに注意してください。

MVC 側のアクションメソッドの認証には、この認証クッキーが使用されます。例えば MVC 側のアクションメソッドに [Authorize] を付与してやると、認証クッキーなしでは 401 応答が返ってきます。

401 応答ではなく、302 応答を返してログインページにリダイレクトするには、引数の CookieAuthenticationOptions オブジェクトの LoginPath プロパティにログインページの URL を new PathString("/Account/Login") というように指定してやります。

(3) AuthenticationType の設定

UseCookieAuthentication メソッドの引数 CookieAuthenticationOptions オブジェクトの AuthenticationType を設定すると、トークンエンドポイント /Token に email と password を送信しても認証クッキーは発行されなくなります。(なお、トークンは発行されます)

認証クッキーは MVC 側でのログインだけで発行したい場合は、AuthenticationType を設定すればよさそうです。そのことが書いてあるドキュメントは見つけられなかったのですが、そのような目的のためにそうなっているような気がします。

AuthenticationType に設定されるのは単なる文字列で、何かの識別用に使っているらしいです。これをきちんと指定しないとログアウトできないなどの問題が出るという stackoverflow の記事を見かけました。(自分が Web API テンプレートのアプリで試した限りではそういう問題は出なかったですが・・・)

(4) トークンによる認証の強制

デフォルトでは Web API 側の認証はトークンになるのは、WebApiConfig.cs にある以下のコードによります。

config.SuppressDefaultHostAuthentication();
config.Filters.Add(new HostAuthenticationFilter(
                OAuthDefaults.AuthenticationType));

前者のメソッドでホスト(IIS と MVC)による認証は無視され、Web API には匿名アクセスとなるらしいです。後者のメソッドで HostAuthenticationFilter によりベアラトークンによる認証が行われるようになるそうです。

ちなみに、上の 2 行をコメントアウトすると認証クッキーだけで認証が通ってしまうようになります。

詳しくは、Microsoft の文書 Secure a Web API with Individual Accounts and Local Login in ASP.NET Web API 2.2 の Configuring Web API to use Bearer Tokens のセクションに詳しく書いてありますので、そちらを見てください。

Tags: ,

MVC

ASP.NET Web API のバインディング

by WebSurfer 2018年9月11日 16:52

ASP.NET Web API でもクライアントから送信されてきたデータはアクションメソッドの引数にバインドされますが、その仕組みは ASP.NET MVC とは異なるということを書きます。

ASP.NET Web API

詳しくは MSDN Blog の記事「How WebAPI does Parameter Binding」に書いてありますのでそちらを読んでもらうとして、要点だけ書きますと以下の通りです。

  1. Model Binding または Formatters を利用するという 2 つの方法があって、Web API の場合は、クエリ文字列からパラメータを取得する場合は Model Binding を、ボディから取得する場合は Formatter を使う。
  2. string などのプリミティブ型をアクションメソッドの引数にした場合、引数に属性が付与されてなければクエリ文字列からパラメータを探してモデルバインディングする。
  3. コンプレックス型(例:記事の Customer クラス、下のコードの Hero クラス)の場合、デフォルトでは Formatter を使ってボディからパラメータを取得する。

上記の点を踏まえたサンプルコードを以下にアップしておきます。

Model

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Runtime.Serialization;

namespace WebAPI.Models
{
    public class Hero
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }
}

Api Controller

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Web.Http;
using System.Web;
using WebAPI.Models;
 
namespace WebAPI.Controllers
{
    public class ValuesController : ApiController
    {
        private List<Hero> heroes = new List<Hero> {
              new Hero {Id = 1, Name = "スーパーマン"},
              new Hero {Id = 2, Name = "バットマン"},
              new Hero {Id = 3, Name = "ウェブマトリクスマン"},
              new Hero {Id = 4, Name = "チャッカマン"},
              new Hero {Id = 5, Name = "スライムマン"}
          };

        // GET api/values (Read...すべてのレコードを取得)
        public List<Hero> Get()
        {
            return heroes;
        }

        // GET api/values/5 (Read...id 指定のレコード取得)
        public Hero Get(int id)
        {
            return heroes[id - 1];
        }

        // POST api/values  (Create...レコード追加)
        public List<Hero> Post(Hero postedHero)
        {
            heroes.Add(postedHero);
            return heroes;
        }

        // PUT api/values/5 (Update...id 指定のレコード更新)
        public List<Hero> Put(int id, [FromBody] string name)
        {
            heroes[id - 1].Name = name;
            return heroes;
        }

        // DELETE api/values/5 (Delete...id 指定のレコード削除)
        public List<Hero> Delete(int id)
        {
            heroes.RemoveAt(id - 1);
            return heroes;
        }
    }
}

上の Api を呼び出すスクリプト

<input type="button" value="READ ALL"
       onclick="apiHeroesGet();" />
<input type="button" value="READ 5"
       onclick="apiHeroesGet5();" />
<input type="button" value="UPDATE 5"
       onclick="apiHeroesPut5();" />
<input type="button" value="DELETE 5"
       onclick="apiHeroesDelete5();" />
<input type="button" value="CREATE 6"
       onclick="apiHeroesPost();" />

<ul id="heroes"></ul>

@section Scripts {
  <script type="text/javascript">
  //<![CDATA[

    function apiHeroesGet() {
      $.ajax({
        type: "GET",
        url: "api/values",
        success: function (data, textStatus, jqXHR) {
          $('#heroes').empty();
          $.each(data, function (key, val) {
            var str = val.Id + ': ' + val.Name;
            $('<li/>', { html: str }).appendTo($('#heroes'));
          });
        },
        error: function (jqXHR, textStatus, errorThrown) {
          $('#heroes').empty();
          $('#heroes').text('textStatus: ' + textStatus +
              ', errorThrown: ' + errorThrown);
        }
      });
    }

    function apiHeroesGet5() {
      $.ajax({
        type: "GET",
        url: "api/values/5",
        success: function (data, textStatus, jqXHR) {
          $('#heroes').empty();
          var str = data.Id + ': ' + data.Name;
          $('<li/>', { html: str }).appendTo($('#heroes'));
        },
        error: function (jqXHR, textStatus, errorThrown) {
          $('#heroes').empty();
          $('#heroes').text('textStatus: ' + textStatus +
              ', errorThrown: ' + errorThrown);
        }
      });
    }

    function apiHeroesPut5() {
      $.ajax({
        type: "PUT",
        url: "api/values/5",
        data: encodeURI("=ガッチャマン"),

        // 以下のコードがあるとバインドできず null が渡される
        //contentType: "application/json; charset=utf-8",

        success: function (data) {
          $('#heroes').empty();
          $.each(data, function (key, val) {
            var str = val.Id + ': ' + val.Name;
            $('<li/>', { html: str }).appendTo($('#heroes'));
          });
        },
        error: function (jqXHR, textStatus, errorThrown) {
          $('#heroes').empty();
          $('#heroes').text('textStatus: ' + textStatus +
             ', errorThrown: ' + errorThrown);
        }
      });
    }

    function apiHeroesDelete5() {
      $.ajax({
        type: "DELETE",
        url: "api/values/5",
        success: function (data) {
          $('#heroes').empty();
          $.each(data, function (key, val) {
            var str = val.Id + ': ' + val.Name;
            $('<li/>', { html: str }).appendTo($('#heroes'));
          });
        },
        error: function (jqXHR, textStatus, errorThrown) {
          $('#heroes').empty();
          $('#heroes').text('textStatus: ' + textStatus +
              ', errorThrown: ' + errorThrown);
        }
      });
    }

    function apiHeroesPost() {
      var j = { Id: 6, Name: "ガッチャマンの息子" };
      var jsonString = JSON.stringify(j);
      $.ajax({
        type: "POST",
        url: "api/values",
        data: jsonString,
        contentType: "application/json; charset=utf-8",
        success: function (data) {
          $('#heroes').empty();
          $.each(data, function (key, val) {
            var str = val.Id + ': ' + val.Name;
            $('<li/>', { html: str }).appendTo($('#heroes'));
          });
        },
        error: function (jqXHR, textStatus, errorThrown) {
          $('#heroes').empty();
          $('#heroes').text('textStatus: ' + textStatus +
              ', errorThrown: ' + errorThrown);
        }
      });
    }

  //]]>
  </script>
}

特に注意すべき点は Put(int id, [FromBody] string name) メソッドとそれを呼び出すスクリプトです。

アクションメソッドの引数が string 型でボディからパラメータを得たい場合は [FromBody] 属性が必要です。

さらに、name にバインドするのに Formatter がどういう動きをしているのか調べ切れてませんが、自分が試した限りでは送信するデータをスクリプトの data に "name=value" と設定してはダメで、上のサンプルコードのように "=value" というように設定しないと引数 name にはバインドされませんでした。

もう一つ、Content-Type に application/json を設定するとバインドできず null が渡されてしまいます。理由は、Web API は要求ヘッダの Content-Type を見て適切な Formatter を選択するそうですが、"=value" は JSON ではないからだと思われます。

jQuery ajax で contentType を設定しないと application/x-www-form-urlencoded (デフォルト) となりますが、その場合は期待通り引数 name に "value" がバインドされます。

上記のようなトラブルを避けるために、アクションメソッドの引数は Post(Hero postedHero) と同様にコンプレックス型(Hero クラス)とし、スクリプトのapiHeroesPost() のように JSON 文字列を送信するようにした方が良いかもしれません。

Tags: ,

MVC

MVC5 で AjaxHelper が働かない

by WebSurfer 2018年5月28日 15:40

Visual Studio Community 2015(以下、VS2015 と書きます)のテンプレートで [MVC] を選択して生成した ASP.NET MVC5 アプリケーションでは、そのままでは AjaxHelper(Ajax.BeginForm, Ajax.ActionLink など)が働きません。その理由と解決策を書きます。

Ajax.ActionLink を利用したページ

上の画像は Ajax.ActionLink を使って a 要素の href 属性に部分ビューの url を設定したハイパーリンクをレンダリングし(青文字の部分)、ユーザーがハイパーリンクをクリックすると Ajax を利用して部分ビューを呼び出し、応答を UpdateTargetId に指定した div 要素内に書き出した結果です(Customer Details 以下の部分)。

上の画像のアプリは対策済みなので、Ajax を利用した部分ビューの呼び出しと、UpdateTargetId に指定した div 要素内へ応答の書き出しは成功しています。

未対策の場合はハイパーリンクは普通の a 要素の機能しかないので、クリックすると部分ビューは同期呼び出しされ、ブラウザの現在のウィンドウ全体が部分ビューに書き換えられて表示されます。

なぜそうなるかと言うと、必要な外部 .js ファイルがダウンロードされる設定がされてないからです。

そんな設定は自分でやるのが当たり前と言われるかもしれませんが、Visual Studio 2010 Professional(以下、VS2010 と書きます)のテンプレートで自動生成される MVC4 アプリのプロジェクトでは、AjaxHelper に必要な外部 .js ファイルがダウンロードされる設定が含まれているのです。

なので、VS2015 で MVC5 アプリのプロジェクトを作って、既存の MVC4 の AjaxHelper を使うコードを MVC5 に移植しただけでは期待した Ajax の機能は働きません。

何を隠そう、自分はその理由が分からずハマってしまいました。(汗) また無駄な時間を費やすことがないよう、以下に備忘録として原因と対策を書いておきます。

MVC4 以降は控えめな JavaScript を利用して、Ajax.BeginForm, Ajax.ActionLink などから生成される form 要素、a 要素の属性の設定に応じて Ajax 呼び出しと応答の表示を行うようになっています。

その控えめな JavaScript は jquery.unobtrusive-ajax.js または jquery.unobtrusive-ajax.min.js というファイルに含まれています。(ちなみに、MVC3 以前は「控えめ」ではない MicrosoftAjax.js と MicrosoftMvcAjax.js を使います)

例えば、Ajax.ActionLink を使った場合、上の画像の例の「Ms. Rosmarie Carroll」のハイパーリンクの html 要素は以下のようになります。

<a  data-ajax="true" 
    data-ajax-mode="replace" 
    data-ajax-update="#results" 
    href="/Customer/Details/6">
    Ms. Rosmarie Carroll
</a>

控えめな JavaScript は、上の a 要素の属性 data-ajax, data-ajax-mode, data-ajax-update の設定に従って Ajax 呼び出しと応答の処理を行います。

従って、やるべきことは控えめな JavaScript の外部ファイルがダウンロードされるように設定を行うことです。VS2010 で作る MVC4 アプリはそのあたりの設定が自動的に行われますが、VS2015 で作る MVC5 アプリは開発者が自分で設定しなければなりません。

前置きが長くなりましたが、その方法を以下に書きます。

まず、NuGet から Microsoft.jQuery.Unobstrusive.Ajax をインストールします。以下の画像を見てください。

NuGet からインストール

インストールが完了すると、Script フォルダに jquery.unobtrusive-ajax.js, jquery.unobtrusive-ajax.min.js がインストールされるはずですのでチェックしてください。

スクリプトファイル

App_Start フォルダの BundleConfig.cs ファイルを開いて上記 .js ファイルがバンドルされるように設定します。下の画像の赤枠部分を見てください。ここでは MVC4 と同様に、クライアント側での検証用のスクリプトファイルと一緒にダウンロードされるようにしています。

BundleConfig.cs の設定

_Layout.cshtml には @RenderSection("scripts", required: false) が含まれているので、_Layout.cshtml を使う View では以下のコードを追加すれば OK です。

@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")
}

_Layout.cshtml を使わない View の場合は @Scripts.Render("~/bundles/jqueryval") の位置が jQuery.js の後に来るように注意してください。

Tags:

MVC

About this blog

2010年5月にこのブログを立ち上げました。その後 ブログ2 を追加し、ここは ASP.NET 関係のトピックス、ブログ2はそれ以外のトピックスに分けました。

Calendar

<<  2018年12月  >>
2526272829301
2345678
9101112131415
16171819202122
23242526272829
303112345

View posts in large calendar