IdentityServer4 の認証を差し替える方法

はじめに

外部に公開する REST API の認証・認可に OpenID Connect を選択した場合、ASP.NET Core での実装には IdentityServer4 を使うことになると思う。IdentityServer4 は認証のバックエンドに ASP.NET Core Identity をサポートしているので、新規の開発ならそれを使えばいいかもしれない。

github.com

ただ、独自の認証機能を既に実装している場合、今更 ASP.NET Core Identity に移行するのは困難。そんなときは、IdentityServer4 のカスタム認証バックエンドを作ればいいはず。既に IdentityServer4.AspNetIdentity というお手本があるので、それを参考に挑戦してみた。

IdentityServer4 のホストを作成

IdentityServer4.AspNetIdentity のソースコードを見た感じ、認証をカスタマイズするには IProfileService と IResourceOwnerPasswordValidator を実装して、差し替えれば良さそうだった。

using IdentityModel;
using IdentityServer4.Extensions;
using IdentityServer4.Models;
using IdentityServer4.Services;
using IdentityServer4.Validation;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;

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

        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseUrls("http://localhost:8000");
    }

    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddIdentityServer()
                .AddDeveloperSigningCredential()
                .AddInMemoryApiResources(Config.GetApiResources())
                .AddInMemoryClients(Config.GetClients())
                .AddProfileService<MyProfileService>()
                .AddResourceOwnerValidator<MyResourceOwnerPasswordValidator>();

            services.AddScoped<MyUserManager>();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseIdentityServer();

            app.Run(async (context) =>
            {
                await context.Response.WriteAsync("Hello IdentityServer4!");
            });
        }
    }

    static class Config
    {
        public static IEnumerable<ApiResource> GetApiResources()
        {
            return new List<ApiResource>
            {
                new ApiResource("api1", "My API")
            };
        }

        public static IEnumerable<Client> GetClients()
        {
            return new List<Client>
            {
                new Client
                {
                    ClientId = "ro.client",
                    AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
                    ClientSecrets =
                    {
                        new Secret("secret".Sha256())
                    },
                    AllowedScopes =
                    {
                        "api1",
                    }
                }
            };
        }
    }

    // 独自のユーザー
    public class MyUser
    {
        public string Id { get; set; } = Guid.NewGuid().ToString();

        public string UserName { get; set; }

        public string Password { get; set; }
    }

    // 独自のユーザーを管理
    public class MyUserManager
    {
        static readonly List<MyUser> users = new List<MyUser>
        {
            new MyUser
            {
                UserName = "kagawa",
                Password = "password",
            }
        };

        // 実際に利用するときは非同期メソッドになるだろうから、
        // このサンプルでは必要ないけど非同期メソッドにしておく。

        public Task<MyUser> FindByNameAsync(string userName)
        {
            var user = users.FirstOrDefault(u => u.UserName == userName);
            return Task.FromResult(user);
        }

        public Task<MyUser> FindByIdAsync(string userId)
        {
            var user = users.FirstOrDefault(u => u.Id == userId);
            return Task.FromResult(user);
        }

        public Task<bool> CheckPasswordAsync(MyUser user, string password)
        {
            return Task.FromResult(user.Password == password);
        }

        public Task<IEnumerable<Claim>> GetClaimsAsync(MyUser user)
        {
            var claims = new List<Claim>();
            claims.Add(new Claim(JwtClaimTypes.PreferredUserName, user.UserName));
            claims.Add(new Claim(JwtClaimTypes.Name, user.UserName));
            return Task.FromResult<IEnumerable<Claim>>(claims);
        }
    }

    // ユーザーを取得するサービス
    class MyProfileService : IProfileService
    {
        readonly MyUserManager userManager;

        public MyProfileService(MyUserManager userManager)
        {
            this.userManager = userManager;
        }

        public async Task GetProfileDataAsync(ProfileDataRequestContext context)
        {
            var sub = context.Subject.GetSubjectId();
            var user = await userManager.FindByIdAsync(sub);
            var claims = await userManager.GetClaimsAsync(user);

            // ユーザーの情報をクレームで登録する
            context.IssuedClaims.AddRange(claims);
        }

        public async Task IsActiveAsync(IsActiveContext context)
        {
            var sub = context.Subject.GetSubjectId();
            var user = await userManager.FindByIdAsync(sub);
            context.IsActive = user != null;
        }
    }

    // ユーザーのパスワードを検証するバリデーター
    class MyResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator
    {
        readonly MyUserManager userManager;

        public MyResourceOwnerPasswordValidator(MyUserManager userManager)
        {
            this.userManager = userManager;
        }

        public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
        {
            var user = await userManager.FindByNameAsync(context.UserName);
            if (user != null)
            {
                var result = await userManager.CheckPasswordAsync(user, context.Password);
                if (result)
                {
                    context.Result = new GrantValidationResult(
                        user.Id,
                        OidcConstants.AuthenticationMethods.Password);
                    return;
                }
            }
            context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
        }
    }
}

保護対象の Web API を作成

アクセスしてきたユーザーのクレームを返すだけの Web API を実装した。 IdentityServer4 チュートリアルのまんま。

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Linq;

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

        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseUrls("http://localhost:8001");
    }

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

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc()
                .SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

            services.AddAuthentication();
            services.AddAuthentication("Bearer")
                .AddIdentityServerAuthentication(options =>
                {
                    options.Authority = "http://localhost:8000";
                    options.RequireHttpsMetadata = false;
                    options.ApiName = "api1";
                });
        }

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

            app.UseMvc();
        }
    }

    [Authorize]
    [ApiController]
    [Route("identity")]
    public class IdentityController : ControllerBase
    {
        [HttpGet]
        public IActionResult Get()
        {
            return new JsonResult(
                from c in User.Claims
                select new
                {
                    c.Type,
                    c.Value
                });
        }
    }
}

Web API を呼び出すクライアントを作成

OpenID Connect/OAuth クライアントには IdentityModel を使う。

www.nuget.org

アクセストークンを取得できたら、あとは HttpClient を使って Web API を呼び出す。

using IdentityModel.Client;
using System;
using System.Net.Http;
using System.Threading.Tasks;

namespace ConsoleClient
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.Title = "ConsoleClient";

            MainAsync().GetAwaiter().GetResult();

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

        static async Task MainAsync()
        {
            // メタデータからエンドポイントを取得する
            var disco = await DiscoveryClient.GetAsync("http://localhost:8000");
            if (disco.IsError)
            {
                Console.WriteLine(disco.Error);
                return;
            }

            var tokenClient = new TokenClient(
                address: disco.TokenEndpoint,
                clientId: "ro.client",
                clientSecret: "secret");

            var tokenResponse = await tokenClient.RequestResourceOwnerPasswordAsync(
                userName: "kagawa",
                password: "password",
                scope: "api1");

            if (tokenResponse.IsError)
            {
                Console.WriteLine(tokenResponse.Error);
                return;
            }

            Console.WriteLine(tokenResponse.Json);

            // SampleWebApi を呼び出す
            var client = new HttpClient();
            client.SetBearerToken(tokenResponse.AccessToken);

            var response = await client.GetAsync("http://localhost:8001/identity");
            if (!response.IsSuccessStatusCode)
            {
                Console.WriteLine(response.StatusCode);
            }
            else
            {
                var content = await response.Content.ReadAsStringAsync();
                Console.WriteLine(content);
            }
        }
    }
}

実行結果

アクセストークンの取得と、Web API の呼び出しに成功した。

f:id:griefworker:20181002112110p:plain

おわりに

IdentityServer4.AspNetIdentity を参考に、 IdentityServer4 で使う認証を独自のものに差し替えることができた。今回必要に迫られたので試してみたけど、できれば避けたいことにはかわりない。