WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

ASP.NET Core Web API に Role ベースの承認を追加

by WebSurfer 20. July 2024 13:34

トークンベース (JWT) の認証を実装した ASP.NET Core Web API に Role による承認を追加する話を書きます。(認証と承認は違いますのでご注意ください)

クッキーベースの ASP.NET Identity 認証システムを実装した ASP.NET Web アプリでは、認証に加えて承認によるアクセスコントロールが可能です。承認には Role が使われます。例えばサイト管理者がマネージャー・ゲスト・管理者・メンバーなどといった Role を定義し、ユーザー別に Role をアサインすることにより細かいアクセスコントロールが可能になっています。

例えばアクションメソッドに [Authorize(Roles = "Admin")] という属性を付与したとすると、ユーザーが有効な ID とパスワードでログインして認証は通っていたとしても、Admin という Role を持っていないと承認されず、ユーザーはそのアクションメソッドにはアクセスできなくなります。

それは JWT ベースの認証機能を実装した Web API でも同様です。先の記事「Blazor WASM から ASP.NET Core Web API を呼び出し」で紹介した WeatherForecastController コントローラの Get() メソッドで、以下のように [Authorize] 属性を [Authorize(Roles = "Admin")] に変更してから Blazor アプリからアクセスすると、

// [Authorize]
[Authorize(Roles = "Admin")]
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
    return Enumerable.Range(1, 5).Select(index => new WeatherForecast
    {
        Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    })
    .ToArray();
}

以下の画像のように HTTP 403 Forbidden 応答 (サーバーは要求を理解しましたが、要求の実行を拒否しています・・・と言う意味) が帰ってきてデータの取得に失敗します。ちなみに、認証が通らない場合は HTTP 401 Unauthorized 応答が返ってきます。

HTTP 403 応答

上の画像の #11 では、Blazor アプリからは有効な ID とパスワードが送信されるので認証は通ってトークンが返ってきています。そして、#13 でそのトークンを要求ヘッダに含めて上のアクションメソッド Get に要求をかけています。その結果、サーバー側では認証には成功するものの Admin ロールを持っていないので承認が通らず HTTP 403 応答が返ってきています。

Role による承認が通るようにするには、上の画像の #11 で返されるトークンに Admin ロールを持っているという情報を含める必要があります。それにはトークンを生成する際、Claims 情報として Admin ロールを追加してやります。

具体的には、先の記事「Blazor WASM から ASP.NET Core Web API を呼び出し」で紹介したトークンを発行する API の BuildToken メソッドで、以下のように Admin ロール情報を Claims に追加します。

private string BuildToken()
{
    var key = new SymmetricSecurityKey(
        Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));

    var creds = new SigningCredentials(
        key, SecurityAlgorithms.HmacSha256);

    var token = new JwtSecurityToken(
        issuer: _config["Jwt:Issuer"],
        audience: _config["Jwt:Issuer"],

        // Admin ロール情報を Claims に追加
        claims: [ new Claim(ClaimTypes.Role, "Admin") ],

        notBefore: null,
        expires: DateTime.Now.AddMinutes(30),
        signingCredentials: creds);

    return new JwtSecurityTokenHandler().WriteToken(token);
}

これにより発行されるトークンには Admin ロールを持っているという情報が含まれます。以下の画像は発行されたトークンを JWT というサイトでデコードしたものです。赤枠部分を見てください。

Admin ロール情報を含む JWT

そのトークンを要求ヘッダに含めて要求をかければ承認が通って、下の画像の #6 の通りデータが返され、

200 応答

Blazor アプリには期待通り結果が表示されます。

Blazor アプリの結果の表示

Tags: , , , ,

Web API

.NET Framework 版の Web API にファイルアップロード

by WebSurfer 9. March 2023 10:23

.NET Framework 版の ASP.NET Web API はファイルアップロードの受信に対応してません。正確に言うと multipart/form-data 形式で送信されてきたデータのフォーマッターが実装されていません。

Web API にファイルアップロード

なので、アクションメソッドの引数を Post(HttpPostedFileBase file) というようして、それに multipart/form-data 形式でファイルを送信すると UnsupportedMediaTypeException がスローされ、

"メディアの型 'multipart/form-data' のコンテンツから型 'HttpPostedFileBase' のオブジェクトを読み取るために使用可能な MediaTypeFormatter がありません。"

・・・というエラーになります。

その対処方法を書きます。基本的には Microsoft のドキュメント「ASP.NET Web APIでの HTML フォーム データの送信: ファイルのアップロードとマルチパート MIME」に書いてあった方法です。

例えば以下のような View で HTML5 fetch API を使ってファイルをアップロードするとします。

<form id="form1" method="post" enctype="multipart/form-data">
    <input name="caption" type="text" value="Summer Vacation" />

    <input type="file" name="file" />
</form>

<button type="button" id="button1" class="btn btn-primary">
    アップロード
</button>

<div id="result1"></div>

@section Scripts {
    <script type="text/javascript">
        let resultDiv, uploadButton;

        window.addEventListener('DOMContentLoaded', () => {
            uploadButton = document.getElementById("button1");
            resultDiv = document.getElementById("result1");
            uploadButton.addEventListener('click', uploadFile);
        });

        const uploadFile = async () => {
            let fd = new FormData(document.getElementById("form1"));
            const param = {
                method: "POST",
                body: fd
            }
            const response = await fetch("/api/FileUpload", param);
            if (response.ok) {
                const message = await response.text();
                resultDiv.innerText = message;
            } else {
                resultDiv.innerText = "アップロード失敗";
            }
        };
    </script>
}

送信されるデータは以下のように multipart/form-data 形式になります。

------WebKitFormBoundaryoasm5HIwy0wijTSo
Content-Disposition: form-data; name="caption"

Summer Vacation
------WebKitFormBoundaryoasm5HIwy0wijTSo
Content-Disposition: form-data; name="file"; filename="0305Result.jpg"
Content-Type: image/jpeg

それを .NET Framework ��の ASP.NET Web API のアクションメソッドで取得してサーバーのフォルダにファイルとして書き込むコードを書いてみました。以下の通りです。

説明はコメントに書きましたのでそれを見てください。手抜きでスミマセン。

using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Web;
using System.Web.Http;
using System.Threading.Tasks;
using System.Collections.Specialized;
using System.Text;
using System.Net.Http.Headers;

namespace WebAPI.Controllers
{
    public class FileUploadController : ApiController
    {
        // アクションメソッドは引数無しにする。フォーマッタを呼び出さ
        // ずアクションメソッド内で要求本文を処理するため
        public async Task<HttpResponseMessage> Post()
        {
            // 要求にマルチパート MIME メッセージが含まれているか確認
            if (!Request.Content.IsMimeMultipartContent())
            {
                throw new HttpResponseException(
                               HttpStatusCode.UnsupportedMediaType);
            }

            // アプリケーションルート直下に UploadedFiles というフォル
            // ダを設け、そこにアップロードされてきたファイルを書き込
            // む。以下のコードでそのフォルダの物理パスを取得
            string root = HttpContext.Current.Server
                                     .MapPath("~/UploadedFiles");

            // アップロードされたファイルにファイルストリームを割り当て
            var provider = new MultipartFormDataStreamProvider(root);

            // マルチパート MIME メッセージのすべてのファイル部分を
            // 抽出し、上の root で指定されたフォルダに書き込む。
            // ファイル名は ReadAsMultipartAsync が自動的に一意に
            // なるように生成する
            await Request.Content.ReadAsMultipartAsync(provider);

            // ReadAsMultipartAsync メソッドが完了すると、FileData
            // プロパティで MultipartFileData オブジェクトのコレ
            // クションを取得できる。MultipartFileData オブジェクト
            // からファイルに関する情報を取得できる
            foreach (MultipartFileData file in provider.FileData)
            {
                // ReadAsMultipartAsync メソッドが自動的に生成した
                // ファイル名をフルパスで取得。UploadedFiles フォル
                // ダにはその名前でアップロードされてきたファイルが
                // 書き込まれている
                string source = file.LocalFileName;

                // 要求ヘッダに含まれるファイル名を取得。取得した
                // 文字列は何故か " で囲われるのでそれを除去
                var name = file.Headers.ContentDisposition
                                       .FileName.Replace("\"", "");
                string dist = root + "\\" + name;

                // ReadAsMultipartAsync メソッドが生成したファイル
                // 名を要求ヘッダに含まれるファイル名に書き換える。
                // 同名のファイルが存在する場合は先に削除する
                File.Delete(dist);
                File.Move(source, dist);
            }

            // <input name="caption" type="text" /> で送信されて
            // きたデータは以下のようにして取得できる
            NameValueCollection col = provider.FormData;
            foreach (string s in col.AllKeys)
            {
                Trace.WriteLine($"Key: {s}, Value: {col[s]}");
            }

            string result = "アップロード完了";

            // .NET Framework 版の Web API は JSON を返すのが基本
            // らしく、アクションメソッドの戻り値の型を string にし
            // て return result; とすると送信される文字列は " で囲
            // われてしまうので以下のようにする
            return new HttpResponseMessage
            {
                Content = new StringContent(result,
                                            Encoding.UTF8,
                                            "text/plain")
            };
        }
    }
}

何か見落としがあるような気もしますが、自分が検証した限り期待通りに動きました。

ちなみに、Web API ではなくて MVC5 のコントローラーや ASP.NET Core の Web API には multipart/form-data 形式に対応するフォーマッターが組み込まれているので、上記のようなことをする必要はありません。なので、そちらの方向に進んだ方が正解かもしれません。

Tags: ,

Web API

Web API に CORS 実装 (CORE)

by WebSurfer 21. January 2023 22:50

ASP.NET Core Web API に CORS を実装してみました。下の画像は検証のため別プロジェクトの MVC アプリから fetch API を使って Web API に要求を出してデータを取得し表示したものです。

Web API に CORS 実装

開発環境の IIS Express で動かしているのでホストは同じ localhost ですが、ポートが異なるので要求はクロスドメインになります。上の画像では fetch API のメソッドが PUT なので、ブラウザがまずプリフライトリクエストを出し、その応答を見て要求を出してデータを取得しています。

ASP.NET Core の場合はフレームワーク組み込みの CORS 用のミドルウェアが用意されていて、それを Microsoft のドキュメント「ASP.NET Core でクロスオリジン要求 (CORS) を有効にする」に従って有効にします。

(ちなみに、.NET Framework 版の ASP.NET Web アプリでは、先の記事「クロスドメインの WCF サービス」で書いたように自力で実装していました)

具体的には、Visual Studio 2022 のテンプレートでフレームワーク .NET 6.0 で作った ASP.NET Web API アプリであれば、上に紹介した Microsoft のドキュメントの「名前付きポリシーとミドルウェアを使用した CORS」のセクションに従って Program.cs に以下のコードを追加するだけで CORS は有効になります。

var MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
builder.Services.AddCors(options =>
{
    options.AddPolicy(name: MyAllowSpecificOrigins,
        policy =>
        {
            policy.WithOrigins("https://localhost:44343")
                  .AllowAnyHeader()
                  .AllowAnyMethod();
        });
});

// ・・・中略・・・

app.UseCors(MyAllowSpecificOrigins);

「名前付き」にすると、[EnableCors("{Policy String}")] 属性をコントローラーに付与しないと CORS は有効にならないと思っていたがそうではなかったです。コントローラーには何も付与しなくても上の設定だけで CORS は有効になります。

WithOrigins メソッドに設定した https://localhost:44343 は CORS でのアクセスを許可する要求元です。すべての要求元を許可する AllowAnyOrigin メソッドもあります。詳しくは Microsoft のドキュメント「CorsPolicyBuilder クラス」を見てください。

AllowAnyHeader メソッド、AllowAnyMethod メソッドはプリフライトが必要になる場合は必要です(Any ではなく特定の Header, Method を指定することもできます)。それらが無いとプリフライトの応答ヘッダに、

Access-Control-Allow-Headers
Access-Control-Allow-Methods

・・・が含まれないのでプリフライトの後の要求がブラウザから出ず失敗します。

上の画像のプリフライトリクエストの要求・応答ヘッダを Fiddler でキャプチャした画像を下に貼っておきます。

プリフライトリクエスト

要求側はブラウザの仕事で開発者は何もする必要はありません。プリフライトが必要か否かもブラウザが判断して CORS に必要な要求を出してくれます。

Response Headers の Security の項目は、要求を受けてサーバー側のミドルウェア(上のコード参照)が設定したものです。

参考に検証に使ったコードを下に載せておきます。Windows 10 22H2 の Edge 109.0.1518.61, Chrome 109.0.5414.75, Firefox 109.0, Opera 94.0.4606.65 で確認しました。

Web API

using Microsoft.AspNetCore.Mvc;

namespace WebApi.Controllers
{
    public class Hero
    {
        public int Id { get; set; }
        public string? Name { get; set; }
    }

    [Route("api/[controller]")]
    [ApiController]
    public class ValuesController : ControllerBase
    {
        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...すべてのレコードを取得)
        [HttpGet]
        public List<Hero> Get()
        {
            return heroes;
        }

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

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

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

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

クライアント側 (MVC の View)

@{
    ViewData["Title"] = "ApiCors";
}

<h1>ApiCors</h1>

<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[
        const url = "https://localhost:44371/api/values"; // IIS Express
        //const url = "https://localhost:7216/api/values";    //Kestrel
        const elem = document.querySelector("#heroes");

        const apiHeroesGet = async () => {
            const response = await fetch(url);
            if (response.ok) {
                const data = await response.json();
                elem.innerHTML = "";
                for (let i = 0; i < data.length; i++) {
                    elem.insertAdjacentHTML("beforeend", 
                        `<li>${data[i].id}: ${data[i].name}</li>`);
                }
            } else {
                elem.innerHTML = "失敗";
            }
        };

        const apiHeroesGet5 = async () => {
            const response = await fetch(url + "/5");
            if (response.ok) {
                const data = await response.json();
                elem.innerHTML = "";
                elem.insertAdjacentHTML("beforeend",
                    `<li>${data.id}: ${data.name}</li>`);
            } else {
                elem.innerHTML = "失敗";
            }
        };

        const apiHeroesPut5 = async () => {
            const params = {
                method: "PUT",
                body: '{"Id":5,"Name":"Updated Hero"}',
                headers: { 'Content-Type': 'application/json' }
            }
            const response = await fetch(url + "/5", params);
            if (response.ok) {
                const data = await response.json();
                elem.innerHTML = "";
                for (let i = 0; i < data.length; i++) {
                    elem.insertAdjacentHTML("beforeend",
                        `<li>${data[i].id}: ${data[i].name}</li>`);
                }
            } else {
                elem.innerHTML = "失敗";
            }
        };

        const apiHeroesDelete5 = async () => {
            const params = {
                method: "DELETE"
            }
            const response = await fetch(url + "/5", params);
            if (response.ok) {
                const data = await response.json();
                elem.innerHTML = "";
                for (let i = 0; i < data.length; i++) {
                    elem.insertAdjacentHTML("beforeend",
                        `<li>${data[i].id}: ${data[i].name}</li>`);
                }
            } else {
                elem.innerHTML = "失敗";
            }
        };

        const apiHeroesPost = async () => {
            const params = {
                method: "POST",
                body: '{"Id":6,"Name":"Created Hero"}',
                headers: { 'Content-Type': 'application/json' }
            }
            const response = await fetch(url, params);
            if (response.ok) {
                const data = await response.json();
                elem.innerHTML = "";
                for (let i = 0; i < data.length; i++)
                {
                    elem.insertAdjacentHTML("beforeend", 
                        `<li>${data[i].id}: ${data[i].name}</li>`);
                }
            } else {
                elem.innerHTML = "失敗";
            }
        };

        //]]>
    </script>
}

本題の CORS の実装とは直接関係ないことですが、fetch メソッドの引数の要求 URL の書き方に注意すべきことがあったのでそれを書いておきます。

PUT と DELETE については、Web API 側のアクションメソッドの引数に int id が含まれているので、要求 URL に api/values/5 というように id を渡す必要があります。そうしないと、プリフライトリクエストは成功しますが、それを受けてブラウザが要求に行った際に応答が 405 Method Not Allowed、Allow: GET, POST となって失敗します。

何故それで Method Not Allowed となるのか、GET, POST でないとダメと言われるのかは分かりませんが・・・

Tags: ,

Web API

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  July 2024  >>
MoTuWeThFrSaSu
24252627282930
1234567
891011121314
15161718192021
22232425262728
2930311234

View posts in large calendar