はじめに
外部に公開する REST API の認証・認可に OpenID Connect を選択した場合、ASP.NET Core での実装には IdentityServer4 を使うことになると思う。IdentityServer4 は認証のバックエンドに ASP.NET Core Identity をサポートしているので、新規の開発ならそれを使えばいいかもしれない。
ただ、独自の認証機能を既に実装している場合、今更 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 を使う。
アクセストークンを取得できたら、あとは 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 の呼び出しに成功した。
おわりに
IdentityServer4.AspNetIdentity を参考に、 IdentityServer4 で使う認証を独自のものに差し替えることができた。今回必要に迫られたので試してみたけど、できれば避けたいことにはかわりない。