先の記事「Duende IdentityServer」で作成した認証サーバーを利用して ASP.NET Core Web API アプリのユーザー認証ができるようにしてみます。
Duende Software のドキュメント Protecting an API using Client Credentials に例が載っていますが、その記事では Machine to Machine で(即ち、個々のユーザー認証なしで)トークンを取得しているところを、IdentityServer に登録済みのユーザーの id と password で認証を受けてトークンを取得するように変更しました。
個々のユーザーのクレデンシャルを使うのは望ましくないというような意味のことがドキュメントに書いてあった気がしますが、せっかく苦労してサンプルを作ったので以下にその手順を書いておきます。
(1) Web API プロジェクトの作成
Visual Studio 2022 の「ASP.NET Core Web API」のテンプレートを使って[フレームワーク(F)]を「.NET 6.0 (長期的なサポート)」とし[認証の種類(A)]を「なし」にして Web API プロジェクトを作成します。
(2) NuGet パッケージのインストール
Microsoft.AspNetCore.Authentication.JwtBearer を NuGet からインストールします。
(3) JWT 認証スキーマを登録
Web API プロジェクトに含まれる Program.cs のコードを編集して JWT 認証スキーマを登録します。Duende Software のドキュメントの Adding an API と Authorization at the API を参考にしました。
.NET 6.0 のプロジェクトの Program.cs では以下のようになります。自動生成されたコードに「// 追加」とコメントしたコードを追加します。
// 追加
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
// 追加
builder.Services.AddAuthentication(
JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://localhost:5001";
options.TokenValidationParameters =
new TokenValidationParameters
{
ValidateAudience = false
};
});
// 追加
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ApiScope", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "scope3");
});
});
// ・・・中略・・・
// 追加
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
上のコードで、options.Authority に設定する URL は先に作成した Duende IdentityServer の URL に合わせてください。デフォルトでは上のように "https://localhost:5001" となっているはずです。
policy.RequireClaim("scope", "scope3") の "scope3" は後で Duende IdentityServer プロジェクトの Config.cs ファイルで設定します。下のステップ (5) のコードを見てください。
(4) [Authorize] 属性を付与
自動生成された WeatherForecastController コントローラの Get() メソッドに [Authorize] 属性を付与します。using Microsoft.AspNetCore.Authorization; の追加を忘れないようにしてください。
ここまでの設定で JWT トークンベースのアクセス制限の実装は完了しており、トークンなしで WeatherForecastController コントローラの Get() メソッドを要求すると HTTP 401 Unauthorized 応答が返ってくるはずですので試してみてください。
(5) IdentityServer に Client を追加
先に作成済の Duende IdentityServer プロジェクトの Config.cs ファイルを開いて上の Web API アプリの認証をサポートするための Client を追加します。以下の「// 追加」とコメントしたコードを既存のファイルに追加します。
using Duende.IdentityServer.Models;
namespace DuendeIdentityServer
{
public static class Config
{
// ・・・中略・・・
public static IEnumerable<ApiScope> ApiScopes => new ApiScope[] {
new ApiScope("scope1"),
new ApiScope("scope2"),
// 追加
new ApiScope("scope3")
};
public static IEnumerable<Client> Clients => new Client[] {
// ・・・中略・・・
// 追加
new Client
{
ClientId = "WebApiNet6",
ClientSecrets = { new Secret("0C86E143-30E0-4FB4-8710-008CD861BF5B".Sha256()) },
AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
AllowedScopes = { "scope3" }
}
};
}
}
Duende Software のドキュメントの例では Machine to Machine communication でトークンを取得していますが、そこを Issuing Tokens based on User Passwords と Token Endpoint を参考に id と password を送信してトークンを取得できるように変更しました。AllowedGrantTypes を GrantTypes.ResourceOwnerPassword に設定するのがキモらしいです。
(6) クライアントアプリの作成
IdentityServer に登録済みのユーザーの id と password を送信してトークンを取得し、そのトークンを要求ヘッダに設定して Web API の WeatherForecast アクションメソッドを GET 要求して結果を表示する検証用のアプリを作成します。
Duende Software のドキュメント Creating the client を参考に .NET 6.0 のコンソールアプリとして作成しました。
コードは以下の通りです。NuGet で IdentityModel をインストールしないと動かないので注意してください。
using IdentityModel.Client;
using System.Text.Json;
var client = new HttpClient();
var disco = await client
.GetDiscoveryDocumentAsync("https://localhost:5001");
if (disco.IsError)
{
Console.WriteLine(disco.Error);
return;
}
var tokenResponse2 = await client.RequestPasswordTokenAsync(
new PasswordTokenRequest
{
Address = disco.TokenEndpoint,
ClientId = "WebApiNet6",
ClientSecret = "0C86E143-30E0-4FB4-8710-008CD861BF5B",
Scope = "scope3",
UserName = "alice",
Password = "Pass123$"
});
if (tokenResponse2.IsError)
{
Console.WriteLine(tokenResponse2.Error);
return;
}
else
{
Console.WriteLine(tokenResponse2.Json);
Console.WriteLine("\n\n");
}
client.Dispose();
var apiClient = new HttpClient();
apiClient.SetBearerToken(tokenResponse2.AccessToken);
var response = await apiClient
.GetAsync("https://localhost:44300/WeatherForecast");
if (!response.IsSuccessStatusCode)
{
Console.WriteLine(response.StatusCode);
}
else
{
var doc = JsonDocument
.Parse(await response.Content.ReadAsStringAsync())
.RootElement;
Console.WriteLine(JsonSerializer
.Serialize(doc,
new JsonSerializerOptions { WriteIndented = true }));
}
apiClient.Dispose();
コンソールアプリの実行結果は以下の���うになります。事前に IdentityServer と Web API を動かしておく必要がありますので注意してください。
一応期待通り動くことは検証できましたが、ホントに上記の設定で良いのかは自信がありません。ひょっとしたら、やってはいけないことをやっているのかもしれませんので、コピペして使うのは避けた方が良いと思います。(笑)