はじめに
外部に公開する 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);
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 で使う認証を独自のものに差し替えることができた。今回必要に迫られたので試してみたけど、できれば避けたいことにはかわりない。