WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

ASP.NET Core Web API と JWT

by WebSurfer 11. February 2020 18:48

ASP.NET Core 3.1 Web API でトークンベース認証を実装してアクセス制限し、ユーザー認証に ASP.NET Core Identity のユーザー情報を利用する方法を書きます。

結果の表示

.NET Framework Web API の場合は、先の記事「ASP.NET Web API の認証」で書きましたように、Visua Studio のテンプレートを使って認証を「個別のユーザーアカウントカウント」として自動生成すればデフォルトでトークンベースの認証が実装され、認証のためのユーザー情報のストアには ASP.NET Identity が使用されます。

Core Web API では自力での実装が必要になります。テンプレートで認証に「個別のユーザーアカウントカウント」を選択すると「クラウドの既存のユーザーストアに接続する」しか選べません。なので、認証なしの状態から Core 2 からサポートされたという JSON Web Token (JWT) を使った認証を実装することにします。

基本的な方法は Auth0 というサイトのブログの記事「ASP.NET Core 2.0 アプリケーションを JWT でセキュアする」(以下「Auth0 の記事」と書きます)に書いてあるのでそれを見れば済む話なのですが、リンク切れになったりすると困るので要点およびその記事には書いてないことを備忘録として残しておきます。

(1) プロジェクトの作成

元になる ASP.NET Core 3.1 Web API アプリのプロジェクトは Visual Studio Community 2019 のテンプレートで自動生成されたものを使います。以下の画像を見てください。認証は「なし」にしておきます。

プロジェクトの作成

テンプレートで自動生成したプロジェクトにはサンプルのコントローラ WeatherForecastController が実装されていて、Visual Studio からプロジェクトを実行([デバッグ(D)]⇒[デバッグなしで開始(H)])すると JSON 文字列が返ってきます。

そのアクションメソッド Get() に JWT ベースの認証を実装します(即ち、トークンが無いとアクセス拒否するようにします)。

(2) NuGet パッケージのインストール

下の画像の赤枠で囲んだ Microsoft.AspNetCore.Authentication.JwtBearer を NuGet からインストールします。青枠で囲んだものは、下に述べるユーザー認証に ASP.NET Core Identity のユーザー情報を利用する場合に必要になります。

NuGet パッケージのインストール

(3) JWT 認証スキーマを登録

自動生成された Startup.cs のコードの ConfigureServices メソッドで、AddAuthentication メソッドを使って JWT 認証スキーマを登録します。コードは Auth0 の記事のものをそのままコピペすれば OK です。using 句の追加を忘れないようにしてください。

さらに、認証を有効にするため Configure メソッドに app.UseAuthentication(); を追加します。既存のコードの app.UseAuthorization(); の前にする必要があるので注意してください。

具体的には以下のコードで「JWT ベースの認証を行うため追加」とコメントしたコードを追加します。

// ・・・前略・・・

// JWT ベースの認証を行うため追加
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

//・・・中略・・・

  public void ConfigureServices(IServiceCollection services)
  {
    // JWT ベースの認証を行うため追加
    services.AddAuthentication(
        JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
          options.TokenValidationParameters = 
            new TokenValidationParameters
            {
              ValidateIssuer = true,
              ValidateAudience = true,
              ValidateLifetime = true,
              ValidateIssuerSigningKey = true,
              ValidIssuer = Configuration["Jwt:Issuer"],
              ValidAudience = Configuration["Jwt:Issuer"],
              IssuerSigningKey = new SymmetricSecurityKey(
                  Encoding.UTF8.GetBytes(
                      Configuration["Jwt:Key"]))
            };
        });

    services.AddControllers();
  }

  public void Configure(IApplicationBuilder app, 
            IWebHostEnvironment env)
  {
    // ・・・中略・・・

    // JWT ベースの認証を行うため追加
    app.UseAuthentication();

    app.UseAuthorization();

    //・・・後略・・・

(4) Key と Issuer を appsettings.json に登録

上の (3) コードでは Key と Issuer を appsettings.json ファイルより取得するようにしていますので、以下のように "Jwt" 要素を追加します。

{

  ・・・中略・・・

  "AllowedHosts": "*",
  "Jwt": {
    "Key": "veryVerySecretKey",
    "Issuer": "https://localhost:44330"
  }
}

"AllowedHosts": "*" の後にカンマ , を追加するのを忘れないようにしてください。Key はパスワードのようなもので任意の文字列を設定できます(16 文字以上にしないとエラーになるようです)。Issuer はサービスを行う URL にします。

(5) [Authorize] 属性を付与

自動生成された WeatherForecastController コントローラの Get() メソッドに [Authorize] 属性を付与します。using Microsoft.AspNetCore.Authorization; の追加を忘れないようにしてください。

ここまでの設定で JWT トークンベースのアクセス制限の実装は完了しており、トークンなしで WeatherForecastController コントローラの Get() メソッドを要求すると HTTP 401 Unauthorized 応答が返ってくるはずです。

(6) トークンを取得する API を実装

ユーザーの ID とパスワードを送信してトークンを取得する API を実装します。基本的には Auth0 の記事のコントローラ TokenController の通りですが、それを拡張してユーザー情報を既存の ASP.NET Core Identity のデータベースから取得して認証を行うようにしてみました。

コントローラ TokenController のコードは以下の通りです。UserManager<IdentityUser> オブジェクトへの参照を DI によって取得し、それを使って既存の ASP.NET Core Identity から情報を取得してユーザー認証に用いています。

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
// 以下を追加
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using Microsoft.AspNetCore.Identity;

namespace WebApiJwtIdentity.Controllers
{
  [Route("api/[controller]")]
  [ApiController]
  public class TokenController : ControllerBase
  {
    private readonly IConfiguration _config;
    private readonly UserManager<IdentityUser> _userManager;

    public TokenController(IConfiguration config, 
            UserManager<IdentityUser> userManager)
    {
      _config = config;
      _userManager = userManager;
    }

    [AllowAnonymous]
    [HttpPost]
    public async Task<IActionResult> CreateToken(
                                        LoginModel login)
    {
      string id = login.Username;
      string pw = login.Password;
      IActionResult response = Unauthorized();
      var user = await _userManager.FindByNameAsync(id);
      if (user != null && 
          await _userManager.CheckPasswordAsync(user, pw))
      {
        var tokenString = BuildToken();
        response = Ok(new { token = tokenString });
      }

      return response;
    }

    private string BuildToken()
    {
      var key = new SymmetricSecurityKey(
          Encoding.UTF8.GetBytes(_config["Jwt:Key"]));
      var creds = new SigningCredentials(
          key, SecurityAlgorithms.HmacSha256);

      var token = new JwtSecurityToken(
        _config["Jwt:Issuer"],
        _config["Jwt:Issuer"],
        expires: DateTime.Now.AddMinutes(30),
        signingCredentials: creds);

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

  public class LoginModel
  {
    public string Username { get; set; }
    public string Password { get; set; }
  }
}

上記のコード以外にも以下の追加が必要です。

(a) 上の (2) の画像で青枠で囲んだ NuGet パッケージのインストール。

(b) IdentityDbContext を継承した ApplicationDbContext クラスを追加。Data フォルダを作ってそれにクラスファイルとして実装します。

using System;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace WebApiJwtIdentity.Data
{
    public class ApplicationDbContext : IdentityDbContext
    {
        public ApplicationDbContext(
            DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }
    }
}

(c) appsettings.json に ASP.NET Core Identity が使う既存の SQL Server DB への接続文字列を追加。

(d) Startup.cs に以���を追加。

// 追加
using Microsoft.AspNetCore.Identity;
using WebApiJwtIdentity.Data;
using Microsoft.EntityFrameworkCore;

public void ConfigureServices(IServiceCollection services)
{
  // 追加
  services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(
      Configuration.GetConnectionString(
        "MvcCoreIdentityContextConnection")));

  // 追加
  services.AddDefaultIdentity<IdentityUser>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

(7) 検証用 Home/Index ページを追加

以下は必須ではないですが、検証用の Home/Index ページを追加し、そこから jQuery ajax を使ってトークンの取得と認証が期待通りとなるかを確認してみます。

View のコードは以下のようになります。下のコードの Username と Password には "***" ではなくて有効な文字列を設定してください。このページを使って確認した結果が一番上の画像です。

@{
    Layout = null;
}

<!DOCTYPE html>

<html>
<head>
  <meta name="viewport" content="width=device-width" />
  <title>Index</title>
  <script src="~/Scripts/jquery.js"></script>
  <script type="text/javascript">
  //<![CDATA[
    var tokenKey = 'accessToken';

    function getToken() {            
      var obj = { Username : "***", Password : "***" };
      var jsonString = JSON.stringify(obj);
      $.ajax({
        type: "POST",
        url: "/api/token",
        data: jsonString,
        contentType: "application/json; charset=utf-8",
        success: function (data, textStatus, jqXHR) {
          sessionStorage.setItem(tokenKey, data.token);
        },
        error: function (jqXHR, textStatus, errorThrown) {
          $('#output').empty();
          $('#output').text('textStatus: ' + textStatus +
              ', errorThrown: ' + errorThrown);
        }
      });
    }

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

      $.ajax({
        type: "GET",
        url: "/WeatherForecast",
        headers: headers,
        cache: false,
        success: function (data, textStatus, jqXHR) {
          $('#output').empty();
          $.each(data, function (key, val) {
            var day = new Date(val.date);
            var dateString = day.getFullYear() + "年" +
                (day.getMonth() + 1) + "月" +
                day.getDate() + "日";
            $('#output').append(
              '<p>' + dateString + ' / ' +
              val.temperatureC + ' / ' +
              val.temperatureF + ' / ' +
              val.summary + '</p >');
          });                    
        },
        error: function (jqXHR, textStatus, errorThrown) {
          $('#output').empty();
          $('#output').text('textStatus: ' + textStatus +
              ', errorThrown: ' + errorThrown);
        }
      });
    }

  //]]>
  </script>
</head >
<body>
    <h3>Web API から jQuery ajax を使ってデータの取得</h3>

    <input type="button" id="Button1" 
      value="WeatherForecast" onclick="weatherForecast();" />
    <input type="button" id="Button2" 
      value="Get Token" onclick="getToken();" />

    <hr />
    <p>結果の表示:</p>
    <div id="output"></div>
</body>
</html >

なお、元々のプロジェクトの設定が Web API 用ですので、そのままでは MVC 用の Controller と View は動きませんので注意してください。以下の設定が必要になります。

(a) Startup.cs で MVC 用のサービスの追加、静的ファイルの利用を可能にすること、ルーティングのためのマップ設定。

(b) launchSettings.json で "launchUrl" の "weatherforecast" を "home/index" に変更。

(c) jQuery を利用するので wwwroot/Script/jQuery.js を追加。

Tags: , , ,

CORE

ASP.NET Core MVC の Bundle と Minify

by WebSurfer 6. February 2020 15:49

ASP.NET Core 3.1 MVC で .css ファイルと .js ファイルをバンドル&ミニファイする機能を実装しようとしてハマった話を書きます。

Bundle と Minify

手順は Microsoft のドキュメント Bundle and minify static assets in ASP.NET Core に詳しく書いてあります。

ドキュメントにはいろいろ書いてありますが、ビルド時にバンドル&ミニファイ版の .css ファイル、.js ファイルを生成するなら下の (1), (2) の手順だけで可能です。

(1) Configure bundling and minification のセクションに従ってアプリケーションルートに bundleconfig.json を追加。以下にドキュメントに記載されている例を書いておきます。詳しい説明はドキュメントを読んでください。

[
  {
    "outputFileName": "wwwroot/css/site.min.css",
    "inputFiles": [
      "wwwroot/css/site.css"
    ]
  },
  {
    "outputFileName": "wwwroot/js/site.min.js",
    "inputFiles": [
      "wwwroot/js/site.js"
    ],
    "minify": {
      "enabled": true,
      "renameLocals": true
    },
    "sourceMap": false
  }
]

(2) Build-time execution of bundling and minification のセクションに従って BuildBundlerMinifier を NuGet からインストール。(BundlerMinifier.Core ではないので注意)

BuildBundlerMinifier をインストール

ここまでの設定で、Visual Studio でプロジェクトをビルドする時、bundleconfig.json の inputFiles に指定した .css, .js ファイルをバンドル&ミニファイして outputFileName に指定したパス/ファイル名で配置してくれます。一番上の画像の赤枠で囲ったファイルを見てください。

Visual Studio が自動的にやってくれるのはここまでであることに注意してください。

一番上の画像のようにバンドル&ミニファイ版のファイルを作って配置してくれるだけなので、例えば元のソースが以下のように通常版のパスを参照している場合は site.css ⇒ site.min.css、site.js ⇒ site.min.js に書き換える必要があります。ちなみに、テンプレートで自動生成される _Layput.cshtml がデフォルトで下記のようになっています。

<link rel="stylesheet" href="~/css/site.css" />
<script src="~/js/site.js"></script>

ここが Microsoft のドキュメントに書いてなくて、何故記事の通りやっているのにバンドル&ミニファイされないのか分からず、半日ぐらいハマってしまったところです。(汗)

そんなの当たり前に分かるだろうと思われるかもしれませんね。でも、.NET Framework MVC の場合は、テンプレートでプロジェクトを自動生成するだけで、web.config で <compilation debug="true" ... > と設定してある時は通常版の .css と .js ファイルが、debug="false" の時はバンドル&ミニファイ版が自動的に設定されるのです。

自分は Core MVC でも .NET Framework MVC と同様に、そこまで面倒見てくれると思い込んでいたので気が付きませんでした。(涙)

Tags: , , ,

CORE

ASP.NET Core MVC の ClaimsIdentity

by WebSurfer 4. February 2020 13:56

ASP.NET Core 3.1 Identity で、プロファイル情報を Claim として ClaimsIdentity オブジェクトに追加し、拡張メソッドを使って ClaimsIdentity オブジェクトからプロファイル情報を取得・表示する方法を書きます。

プロファイル情報の表示

.NET Framework ベースの Identity の場合は先の記事「プロファイル情報を ClaimsIdentity へ追加」に書きました。以下は、それと同様な仕組みを ASP.NET Core 3.1 MVC で実装する方法です。

Core 版 Identity にも、.NET Framework 版と同様に、プロファイル情報の一つとして PhoneNumber が IdentityUser クラスに定義済みです。(なので Entity Framework Code First で自動生成される DB のテーブルには PhoneNumber フィールドが含まれます)

先の記事と同様に、ユーザーがログインした際 PhoneNumber を認証クッキーに含めてブラウザに送信し、次の要求を受けた時に認証クッキーから PhoneNumber を取得して上の画像のようにページの右上に表示するコードを実装してみました。

それに何のメリットがあるかと言うと、認証クッキーからプロファイル情報を取得する方が、いちいちデータベースにクエリを投げて取得するより負荷は軽い(であろう)ということです。詳しくは先の記事を見てください。

問題は Visual Studio のテンプレートで自動生成されるコードのどこに Claim を ClaimsIdentity に追加するためのコードを書くかということです。

.NET Framework 版 MVC5 であれば、自動生成される Models/IdentityModels.cs に定義されている ApplicationUser クラスの GenereteUserIdentityAsync メソッドの中に「// ここにカスタム ユーザー クレームを追加します」とコメントが入っていてすぐわかるのですが・・・

という訳で、ネットで asp.net core add custom claim をキーワードにググって調べて、ヒットした以下の記事を参考にさせていただきました:

以下に、定義済みのプロファイル情報 PhoneNumber を Claim として ClaimsIdentity へ追加するコード、ClaimsIdentity からプロファイル情報を取得するための拡張メソッドのコードを載せておきます。上の 2 つの記事のどちらの方法でも OK ですが、下のサンプルコードでは前者の記事の方法を取っています。

ただし、Role を使っている場合は要注意で、上に紹介した記事にある UserClaimsPrincipalFactory<TUser> クラスを使うと Role が働かなくなります(例えばアクションメソッドに [Authorize(Roles ="Administrator")] を付与とすると Administrator ロールを持っているユーザーでもアクセス拒否されます)。

Role が使われている場合は UserClaimsPrincipalFactory<TUser> クラスに代えて、下のサンプルコードのように UserClaimsPrincipalFactory<TUser,TRole> クラスを使ってください。

using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Security.Principal;

namespace MvcCoreIdentity.Services
{
  // Role を使う場合 UserClaimsPrincipalFactory<TUser> では
  // なく UserClaimsPrincipalFactory<TUser,TRole> を継承
  public class CustomClaimsPrincipalFactory : 
    UserClaimsPrincipalFactory<IdentityUser, IdentityRole>
  {
    public CustomClaimsPrincipalFactory(
        UserManager<IdentityUser> userManager, 
        RoleManager<IdentityRole> roleManager, 
        IOptions<IdentityOptions> optionsAccessor)
        : base(userManager, roleManager, optionsAccessor)
    {
    }

    // ログイン操作でこのメソッドが呼び出される
    public async override Task<ClaimsPrincipal> CreateAsync(
                                          IdentityUser user)
    {
      var principal = await base.CreateAsync(user);

      // ここでは例として PhoneNumber を Claim として追加。
      // 未登録(DB 上で NULL)の場合 this.PhoneNumber プロ
      // パティは null を返す。null の場合は追加しても意味
      // がないので追加しない
      if (!string.IsNullOrEmpty(user.PhoneNumber))
      {
        ((ClaimsIdentity)principal.Identity).AddClaims(
          new[] { 
            new Claim(ClaimTypes.HomePhone, user.PhoneNumber) 
          });
      }

      return principal;
    }
  }

  // ClaimsIdentity から PhoneNumber を取得する拡張メソッド
  // PhoneNumber が Claims にない場合は null を返す。
  public static class MyExtensions
  {
    public static string GetPhoneNumber(this 
                                        IIdentity identity)
    {
      var claimsIdentity = identity as ClaimsIdentity;
      if (claimsIdentity != null)
      {
        var claim = claimsIdentity.Claims.
                    FirstOrDefault(c => 
                        c.Type == ClaimTypes.HomePhone);
        if (claim != null)
        {
          return claim.Value;
        }
      }
      return null;
    }
  }
}

上のコードの CustomClaimsPrincipalFactory メソッドが動くようにするには Startup.cs の ConfigureServices メソッドで以下のように設定する必要がありますので忘れないようにしてください。

public void ConfigureServices(IServiceCollection services)
{

  // ・・・中略・・・

  // 以下のコードを追加する
  services.AddScoped<IUserClaimsPrincipalFactory<IdentityUser>, 
      CustomClaimsPrincipalFactory>();

  services.AddControllersWithViews();
  services.AddRazorPages();
}

上の拡張メソッド GetPhoneNumber は名前空間をインポートすればスコープの中に取り込むことができます。例えば、上の画像のようにマスターページの右上に表示する場合は Views/Shared/_loginPartial.cshtml に以下のように名前空間をインポートし @User.Identity.GetPhoneNumber() というコードを追加します。

@using Microsoft.AspNetCore.Identity
@using MvcCoreIdentity.Services

@inject SignInManager<IdentityUser> SignInManager
@inject UserManager<IdentityUser> UserManager

<ul class="navbar-nav">
@if (SignInManager.IsSignedIn(User))
{
    <li class="nav-item">
        <a id="manage" class="nav-link text-dark" 
           asp-area="Identity" 
           asp-page="/Account/Manage/Index" 
           title="Manage">
            Hello @UserManager.GetUserName(User) / 
            Phone @User.Identity.GetPhoneNumber() !
        </a>
    </li>
・・・以下略・・・

Tags: , , ,

CORE

About this blog

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

Calendar

<<  March 2020  >>
MoTuWeThFrSaSu
2425262728291
2345678
9101112131415
16171819202122
23242526272829
303112345

View posts in large calendar