タイトルの通り、AzureAD認証を使う .net5.0 WebアプリをUbuntuに乗っけようとしたら三日三晩苦戦したので、自分の備忘録、兼、同じことをやろうとしている方の参考に少しでもなればと思い記録します。
社内でExcelで管理しているいろんな事どもをWebアプリ化、Web-API化していこうということをしています。
まず手始めに、会社の勤務カレンダーをAPI化。といってもWebAPIのためのフレームワークを使ってどうこうではなく、バックエンドのMySQLからデータを引っ張ってきてASP.net MVCでjsonを返すだけの単純なものです。jQueryでカレンダー形式にして表示してやるので、jsonが返ってくればなんでもいいわけで。
Windows10の開発環境ではバッチリ動いたので、これを運用環境と同じUbuntu 20.04LTSのテスト環境に乗っけます。
APサーバ内ではWebアプリがKestrel上で[::]:5000で待ち受けています。ブラウザから、APサーバ(api.foo.com)にhttpsでアクセスすると、まずApacheが要求を受け付け、背後のWebアプリにProxyPassを通します。Webアプリは同一ネットワーク内にあるDBサーバとやりとりして、結果をApache経由でブラウザに返します。通常はこれで大丈夫なはずです。
実際に乗っけてみる
設定は、Microsoftの公式ドキュメントを参考にしました。
ただし firewalldじゃなくufwを使っていたりするので完全にその通りではありませんが。
##トラブル発生! OpenID認証ができない
さて、それで実行すると、認証不要の機能はすんなりアクセスできるのですが、認証が必要な画面でAzureADのログイン画面からログインしようとすると、AADSTS50011: The reply URL specified in the request does not match the reply urls configured for the application というエラーが返ってきます。
確認したところ、AzureAD側にはhttps://api.foo.com/signin-oidc
が設定されており、アプリが示す reply URL は http://api.foo.com/signin-oidc
でした。つまりhttpsとhttpの違いではじかれていたのです。
AzureAD側にはhttpsしか登録できませんし、いまどき平文転送なんてありえないので、アプリが示すreply urlをhttpsにする必要があります。
SAPの事例ですが、同様のエラーを見つけたので参考にしました。
httpsで待ち受けるように変更
これを解決する方法は、Kestrelに対してhttpsの口にProxyPassを通してやればいいのですが、そのためにはKestrelをhttpsで動かさなければなりません。
当初は開発用の自己署名証明書でできるかと思ったのですが、ApacheからProxyPassで渡されるサーバ名はapi.foo.comなので(しかもOpenIDのReply URLもapi.foo.comにしなければならないので)、Kestrelに紐づける証明書は api.foo.com のものでなければなりません。
api.foo.com の自己署名証明書の作成
Apacheには Let's Encrypt で作成した証明書を設定しているのですが、有効期間が90日しかなくあとあと面倒くさそうなので、Kestrel側に設定する証明書はOpenSSLを使って自己署名証明書を作成してやります。
拡張鍵用途「サーバ認証」が設定された証明書でなければならない(らしい。設定されてないものを試してないので不明)ので、以下を参考に作成します。
そして、証明書はpfx形式でなければならない(らしい。.net5.0からはcerでもいいらしいけど不明)ので、以下を参考に変換します。
My証明書ストアへの格納
作成したpfxの証明書は My証明書ストアに保存します。~/.dotnet/corefx/cryptgraphy/x509stores/my/
が証明書ストアですが、ここに単純にPFXをコピーするだけでは認識してくれないようです。証明書登録するためのコマンドが標準であるのかもしれませんが、よくわからなかったので私は以下のようなコンソールアプリを作って登録しました。(※ツール、あるようです(後述))
using System;
using System.Security.Cryptography.X509Certificates;
namespace addcert
{
class Program
{
static void Main(string[] args)
{
if( args.Length == 3 && args[0] == "add")
{
addCert(args[1], args[2]);
}
else
{
showUsage();
}
}
static void showUsage()
{
Console.WriteLine("Usage: dotnet addcert add FILE PASSWORD");
}
static void addCert(string certfile, string password)
{
using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser))
{
store.Open(OpenFlags.ReadWrite);
var cert = new X509Certificate2(certfile, password);
store.Add(cert);
}
}
}
}
dotnet addcert.dll foo.pfx password
として実行すると、先ほどのmy
配下に、証明書が格納されます。ファイル名は<証明書の拇印>.pfx
です。(証明書の拇印.pfxのファイル名で単にcpするだけでいいかどうかは試してません。api.foo.com.pfx をコピーするだけではだめでした。)
ツールあるっぽい
によると、標準ツールがあるようです。
#Program.cs と appsettings.jsonの変更
Program.csのCreateHostBuilderを以下のように書き換えます。
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
//webBuilder.UseUrls("http://*:5000;https://*:5001");
webBuilder.UseKestrel(options =>
{
options.Listen(IPAddress.IPv6Loopback, 5000);
options.Listen(IPAddress.IPv6Loopback, 5001, listenOptions =>
{
listenOptions.UseHttps(httpsOptions =>
{
var localhostCert = CertificateLoader.LoadFromStoreCert(
"localhost", "My", StoreLocation.CurrentUser,
allowInvalid: true);
var apiCert = CertificateLoader.LoadFromStoreCert(
"api.foo.com", "My", StoreLocation.CurrentUser,
allowInvalid: true);
var certs = new Dictionary<string, X509Certificate2>(StringComparer.OrdinalIgnoreCase);
certs["localhost"] = localhostCert;
certs["api.foo.com"] = apiCert;
httpsOptions.ServerCertificateSelector = (connectionContext, name) =>
{
if (name != null && certs.TryGetValue(name, out var cert))
{
return cert;
}
return localhostCert;
};
});
});
});
webBuilder.UseStartup<Startup>();
});
このコードは、以下の公式サイトの ServerCertificateSelector による SNI を参考にしました。本当は証明書ストアの証明書を全部読み込んでから、要求URLに該当するものがあるかで判定してやるのがいいように覆いますが、とりあえず動くのでそのままにしています。
ちなみに私の環境ではバックエンドはIPv6で繋がればそれでいいのでIPv6Loopback(つまり[::1])だけで待ち受けています。
ちなみに、証明書ストアの場所がCurrentUserなのが気になります。StoreLocation.LocalMachineにしてやると、先ほどの証明書登録時に Unix LocalMachine X509Stores are read-only for all users.
だと怒られます。
を見ても、Unix系OSではLocalMachine自体が使えないという記述はなく、エラーメッセージを見てもRead-Onlyだ、とのエラーなので、dotnetからはread-onlyだけどLinuxのシステムツールで書き込んでやれば読み出しはできる、という意味なんだろうと思っています。が、まだよくわかっていません。
appsettings.json はこんな感じ。
{
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "foo.com",
"TenantId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"ClientId": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
"CallbackPath": "/signin-oidc",
"SignedOutCallbackPath": "/signout-callback-oidc",
"ClientSecret": "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
"ClientCertificates": []
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DatabaseConnection": "Server=xxxxxxxxxxxxxxxx;Database=xxxxxxxx;User Id=xxxxxxxx;Password=xxxxxxxx;"
},
"MicrosoftGraph": {
"Scopes": "user.read",
"BaseUrl": "https://graph.microsoft.com/v1.0"
},
"BaseURL": "https://api.foo.com",
"https_port": 443
}
サーバのApache設定ファイルは以下。
ProxyRequests Off
SSLProxyEngine On
ProxyPreserveHost On
ProxyPass / https://[::1]:5001/
ProxyPassReverse / https://[::1]:5001/
ProxyRequests は Off でいいそうで、Onにするとオープンプロキシになっていろいろ迷惑かけることがあるそうな。名前が紛らわしいんだよね。バックエンドにSSLでつなぐには SSLProxyEngine On にする必要があり、そしてバックエンドにも api.foo.com のホスト名を渡すためにはProxyPreserveHost On が必要。これがOffだと認証後に端末側のlocalhostに飛ばされてしまいます。
#まとめ
- AzureAD認証(OpenID認証)を使う .net Webアプリは https で待ち受ける必要がある
- Kestrel に設定する電子証明書は自己署名でいいが、localhost ではなく実際に公開するURIである必要がある
- My証明書ストアにはただ単にファイルをコピーするだけでは登録できない