HTTP.sys でホストした Web API で Windows 認証と JWT Bearer 認証を共存させる

ASP.NET Core MVC で実装した Web API を HTTP.sys でホストすることで、利用に Windows 認証が必要な Web API を実現できた。

tnakamura.hatenablog.com

これでひと段落と思いきや、JWT Bearer 認証もサポートする必要が出てきたので、HTTP.sys でホストした状態で Window 認証と JWT Bearer 認証を両方使えるか試してみた。

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Server.HttpSys;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;

namespace HttpSysSample
{
    // アプリケーションの構成
    // 本来は application.json か環境変数に持たせるべきだが、
    // 今回は簡略化のために static クラスにしておく。
    static class AppConfiguration
    {
        public const string SiteUrl = "http://localhost:5000";

        // JWT の署名で使う秘密鍵
        public const string SecretKey = "<your-secret-key>";
    }

    // トークンを取得するために渡された認証情報を格納する
    public class TokenInputModel
    {
        [Required]
        public string UserName { get; set; }

        [Required]
        public string Password { get; set; }
    }

    // 生成したトークンと有効期限を格納する
    public class TokenViewModel
    {
        public string Token { get; set; }

        public DateTime Expiration { get; set; }
    }

    [ApiController]
    [Route("api/[controller]")]
    public class HomeController : ControllerBase
    {
        // 匿名で利用できる API
        [HttpGet("anonymous")]
        public IActionResult Anonymous()
        {
            var user = User.Identity;
            return Ok(new
            {
                user.Name,
                user.IsAuthenticated,
                user.AuthenticationType,
            });
        }

        // Windows 認証が必要な API
        [HttpGet("windows")]
        [Authorize]
        public IActionResult Windows()
        {
            var user = User.Identity;
            return Ok(new
            {
                user.Name,
                user.IsAuthenticated,
                user.AuthenticationType,
            });
        }

        // アクセストークンを取得するための API
        [HttpPost("token")]
        public IActionResult Token([FromBody]TokenInputModel inputModel)
        {
            if (ModelState.IsValid)
            {
                // 簡略化のためユーザー名とパスワードは固定
                if (inputModel.UserName == "admin" &&
                    inputModel.Password == "admin123")
                {
                    var token = CreateJwtSecurityToken(inputModel.UserName);
                    return Ok(new TokenViewModel
                    {
                        Token = new JwtSecurityTokenHandler().WriteToken(token),
                        Expiration = token.ValidTo,
                    });
                }
            }
            return BadRequest();
        }

        JwtSecurityToken CreateJwtSecurityToken(string userName)
        {
            // JWT に含めるクレーム
            var claims = new List<Claim>()
            {
                // JwtBearerAuthentication 用
                new Claim(JwtRegisteredClaimNames.Jti, userName),
                new Claim(JwtRegisteredClaimNames.Sub, userName),
                // User.Identity プロパティ用
                new Claim(ClaimTypes.Sid, userName),
                new Claim(ClaimTypes.Name, userName),
            };

            var token = new JwtSecurityToken(
                issuer: AppConfiguration.SiteUrl,
                audience: AppConfiguration.SiteUrl,
                claims: claims,
                expires: DateTime.UtcNow.AddDays(7),
                signingCredentials: new SigningCredentials(
                    new SymmetricSecurityKey(Encoding.UTF8.GetBytes(AppConfiguration.SecretKey)),
                    SecurityAlgorithms.HmacSha256
                )
            );

            return token;
        }

        // アクセストークンが必要な API
        [HttpGet("jwt-bearer")]
        [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
        public IActionResult JwtBearer()
        {
            var user = User.Identity;
            return Ok(new
            {
                user.Name,
                user.IsAuthenticated,
                user.AuthenticationType,
            });
        }
    }

    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            // Authorize 属性を付けたアクションだけ認証必須にしたい場合は
            // HTTP.sys 用の認証スキーマを登録しておく必要がある。
            services.AddAuthentication(HttpSysDefaults.AuthenticationScheme)
                .AddJwtBearer(options =>
                {
                    options.Audience = AppConfiguration.SiteUrl;
                    options.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidateIssuerSigningKey = true,
                        ValidateIssuer = true,
                        ValidIssuer = AppConfiguration.SiteUrl,
                        IssuerSigningKey = new SymmetricSecurityKey(
                            Encoding.UTF8.GetBytes(AppConfiguration.SecretKey))
                    };
                });

            services.AddMvc()
            .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseAuthentication();

            app.UseMvc();
        }
    }

    public class Program
    {
        public static void Main(string[] args)
        {
            CreateWebHostBuilder(args).Build().Run();
        }

        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseHttpSys(options =>
                {
                    // Windows 認証を有効にする
                    options.Authentication.Schemes = AuthenticationSchemes.NTLM | AuthenticationSchemes.Negotiate;

                    // Authorize 属性を付けたアクションだけ認証必須にしたいので、
                    // 匿名アクセスを許可する
                    options.Authentication.AllowAnonymous = true;
                });
    }
}

Web ブラウザを使って手作業で JWT Bearer 認証を試すのは面倒なので、コンソールアプリケーションを書いてみた。HttpClient で Window 認証必須の Web API を利用するには、HttpClientHandler.UseDefaultCredentials を true にすればみたいだ。

using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;

namespace SampleClient
{
    class Program
    {
        static void Main(string[] args)
        {
            MainAsync().GetAwaiter().GetResult();

            Console.WriteLine("Press Enter Key");
            Console.ReadLine();
        }

        async static Task MainAsync()
        {
            var handler = new HttpClientHandler()
            {
                UseDefaultCredentials = true,
            };
            var client = new HttpClient(handler)
            {
                BaseAddress = new Uri("http://localhost:5000"),
            };

            // 匿名アクセス可能な API を呼び出す
            Console.WriteLine(
                await client.GetStringAsync("/api/home/anonymous"));

            // Windows 認証が必要な API を呼び出す
            Console.WriteLine(
                await client.GetStringAsync("/api/home/windows"));

            // JWT Bearer 認証が必要な API を呼び出す
            // アクセストークンを Authorize ヘッダーで指定していないので、
            // 呼び出しには失敗する
            try
            {
                Console.WriteLine(
                    await client.GetStringAsync("/api/home/jwt-bearer"));
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }

            // アクセストークンを取得
            var response = await client.PostAsync(
                "/api/home/token",
                new StringContent(
                    content: @"{
                        ""userName"":""admin"",
                        ""password"":""admin123""
                    }",
                    encoding: Encoding.UTF8,
                    mediaType: "application/json"));
            var json = await response.Content.ReadAsStringAsync();
            var jObj = JObject.Parse(json);
            var token = (string)jObj["token"];

            client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

            // JWT Bearer 認証が必要な API を呼び出す
            Console.WriteLine(
                await client.GetStringAsync("/api/home/jwt-bearer"));
        }
    }
}

実行結果は次の通り。 Window 認証と JWT Bearer 認証、それぞれ期待通りに動いていることを確認できた。

f:id:griefworker:20190115144441p:plain