Azure App Service に対する TLS 相互認証の構成
さまざまな種類の認証を有効にすることで、Azure App Service アプリへのアクセスを制限できます。 その方法の 1 つとして、クライアント要求が TLS/SSL を経由するときにクライアント証明書を要求し、その証明書を検証することが挙げられます。 このメカニズムは TLS 相互認証またはクライアント証明書認証と呼ばれます。 この記事では、クライアント証明書認証を使用するようにアプリを設定する方法について説明します。
Note
クライアント証明書の検証はアプリ コードが行います。 App Service がこのクライアント証明書に対して行うのは、この証明書をアプリに転送する処理だけです。
HTTPS ではなく HTTP 経由でサイトにアクセスする場合は、クライアント証明書を受信しません。 したがって、アプリケーションにクライアント証明書が必要な場合は、HTTP 経由でのアプリケーションへの要求を許可しないでください。
Web アプリの準備
カスタム TLS/SSL バインディングを作成したり、App Service アプリのクライアント証明書を有効にしたりするには、App Service プランが Basic、Standard、Premium、または Isolated のいずれかのレベルである必要があります。 Web アプリが、サポートされている価格レベルであることを確認するため、次の手順に従います。
Web アプリに移動する
Azure portal の検索ボックスで、[App Services] を検索して選択します。
[App Services] ページで、Web アプリの名前を選択します。
これで、Web アプリの管理ページが表示されます。
価格レベルの確認
Web アプリ ページの左側メニューの [設定] セクションで、[スケール アップ (App Service プラン)] を選択します。
Web アプリが、カスタム TLS/SSL をサポートしていない F1 または D1 レベルにないことを確認します。
スケール アップする必要がある場合は、次のセクションの手順に従います。 それ以外の場合は、[スケール アップ] ページを閉じて、「App Service プランのスケール アップ」セクションはスキップしてください。
App Service プランのスケール アップ
B1、B2、B3、運用カテゴリの他のレベルなど、非 Free レベルのいずれかを選びます。
終了したら、 [選択] を選択します。
次のメッセージが表示されたら、スケール操作が完了しています。
クライアント証明書を有効にする
クライアント証明書を必要とするようにアプリを設定するには、以下のようにします。
アプリの管理ページの左側のナビゲーションで、 [構成]>[全般設定] を選択します。
任意のクライアント証明書モードを選択します。 ページの最上部で [保存] を選択します。
クライアント証明書モード | 説明 |
---|---|
必須 | すべての要求にはクライアント証明書が必要です。 |
省略可能 | 要求がクライアント証明書を使用する場合と使用しない場合があります。 既定では、クライアントは証明書の入力を求められます。 たとえば、ブラウザー クライアントに認証用の証明書を選択するプロンプトが表示されます。 |
オプションの対話ユーザー | 要求がクライアント証明書を使用する場合と使用しない場合があります。 既定では、クライアントは証明書の入力を求められません。 たとえば、ブラウザー クライアントに認証用の証明書を選択するプロンプトが表示されません。 |
Azure CLI を使用して同じことを行うには、Cloud Shell で次のコマンドを実行します。
az webapp update --set clientCertEnabled=true --name <app-name> --resource-group <group-name>
パスを認証を必要としないものとして除外する
お使いのアプリケーションで相互認証を有効にすると、そのアプリのルート下のすべてのパスで、アクセスにクライアント証明書が必要になります。 特定のパスでこの要件を削除するには、アプリケーション構成の一部として除外パスを定義します。
アプリの管理ページの左側のナビゲーションで、 [構成]>[全般設定] を選択します。
[証明書の除外パス] の横にある編集アイコンを選択します。
[新しいパス] を選択し、1 つのパスか、
,
または;
で区切られたパスの一覧を指定して、[OK] を選択します。ページの最上部で [保存] を選択します。
次のスクリーンショットでは、/public
で始まるアプリのパスはクライアント証明書を要求しません。 パスの照合では、大文字と小文字は区別されません。
クライアント証明書にアクセスする
App Service では、要求の TLS 終了がフロントエンドのロード バランサー側で行われます。 App Service がクライアント証明書を有効にした状態で要求をアプリ コードに転送すると、X-ARR-ClientCert
要求ヘッダーにクライアント証明書が挿入されます。 App Service がこのクライアント証明書に対して行うのは、この証明書をアプリに転送する処理だけです。 クライアント証明書の検証はアプリ コードが行います。
ASP.NET の場合は、HttpRequest.ClientCertificate プロパティを通じてクライアント証明書を使用できます。
他のアプリケーション スタック (Node.js や PHP など) の場合は、X-ARR-ClientCert
要求ヘッダー内の base64 エンコード値を通じて、アプリでクライアント証明書を使用できます。
ASP.NET Core のサンプル
ASP.NET Core の場合、転送された証明書を解析するミドルウェアが提供されます。 転送されたプロトコル ヘッダーを使用するために、別のミドルウェアが提供されています。 転送された証明書を受け入れるには、両方が存在している必要があります。 Certificateauthentication オプションには、カスタム証明書検証ロジックを配置できます。
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
// Configure the application to use the protocol and client ip address forwarded by the frontend load balancer
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// Only loopback proxies are allowed by default. Clear that restriction to enable this explicit configuration.
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
// Configure the application to client certificate forwarded the frontend load balancer
services.AddCertificateForwarding(options => { options.CertificateHeader = "X-ARR-ClientCert"; });
// Add certificate authentication so when authorization is performed the user will be created from the certificate
services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme).AddCertificate();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseForwardedHeaders();
app.UseCertificateForwarding();
app.UseHttpsRedirection();
app.UseAuthentication()
app.UseAuthorization();
app.UseStaticFiles();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
}
ASP.NET WebForms のサンプル
using System;
using System.Collections.Specialized;
using System.Security.Cryptography.X509Certificates;
using System.Web;
namespace ClientCertificateUsageSample
{
public partial class Cert : System.Web.UI.Page
{
public string certHeader = "";
public string errorString = "";
private X509Certificate2 certificate = null;
public string certThumbprint = "";
public string certSubject = "";
public string certIssuer = "";
public string certSignatureAlg = "";
public string certIssueDate = "";
public string certExpiryDate = "";
public bool isValidCert = false;
//
// Read the certificate from the header into an X509Certificate2 object
// Display properties of the certificate on the page
//
protected void Page_Load(object sender, EventArgs e)
{
NameValueCollection headers = base.Request.Headers;
certHeader = headers["X-ARR-ClientCert"];
if (!String.IsNullOrEmpty(certHeader))
{
try
{
byte[] clientCertBytes = Convert.FromBase64String(certHeader);
certificate = new X509Certificate2(clientCertBytes);
certSubject = certificate.Subject;
certIssuer = certificate.Issuer;
certThumbprint = certificate.Thumbprint;
certSignatureAlg = certificate.SignatureAlgorithm.FriendlyName;
certIssueDate = certificate.NotBefore.ToShortDateString() + " " + certificate.NotBefore.ToShortTimeString();
certExpiryDate = certificate.NotAfter.ToShortDateString() + " " + certificate.NotAfter.ToShortTimeString();
}
catch (Exception ex)
{
errorString = ex.ToString();
}
finally
{
isValidCert = IsValidClientCertificate();
if (!isValidCert) Response.StatusCode = 403;
else Response.StatusCode = 200;
}
}
else
{
certHeader = "";
}
}
//
// This is a SAMPLE verification routine. Depending on your application logic and security requirements,
// you should modify this method
//
private bool IsValidClientCertificate()
{
// In this example we will only accept the certificate as a valid certificate if all the conditions below are met:
// 1. The certificate isn't expired and is active for the current time on server.
// 2. The subject name of the certificate has the common name nildevecc
// 3. The issuer name of the certificate has the common name nildevecc and organization name Microsoft Corp
// 4. The thumbprint of the certificate is 30757A2E831977D8BD9C8496E4C99AB26CB9622B
//
// This example doesn't test that this certificate is chained to a Trusted Root Authority (or revoked) on the server
// and it allows for self signed certificates
//
if (certificate == null || !String.IsNullOrEmpty(errorString)) return false;
// 1. Check time validity of certificate
if (DateTime.Compare(DateTime.Now, certificate.NotBefore) < 0 || DateTime.Compare(DateTime.Now, certificate.NotAfter) > 0) return false;
// 2. Check subject name of certificate
bool foundSubject = false;
string[] certSubjectData = certificate.Subject.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
foreach (string s in certSubjectData)
{
if (String.Compare(s.Trim(), "CN=nildevecc") == 0)
{
foundSubject = true;
break;
}
}
if (!foundSubject) return false;
// 3. Check issuer name of certificate
bool foundIssuerCN = false, foundIssuerO = false;
string[] certIssuerData = certificate.Issuer.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
foreach (string s in certIssuerData)
{
if (String.Compare(s.Trim(), "CN=nildevecc") == 0)
{
foundIssuerCN = true;
if (foundIssuerO) break;
}
if (String.Compare(s.Trim(), "O=Microsoft Corp") == 0)
{
foundIssuerO = true;
if (foundIssuerCN) break;
}
}
if (!foundIssuerCN || !foundIssuerO) return false;
// 4. Check thumbprint of certificate
if (String.Compare(certificate.Thumbprint.Trim().ToUpper(), "30757A2E831977D8BD9C8496E4C99AB26CB9622B") != 0) return false;
return true;
}
}
}
Node.js のサンプル
次の Node.js サンプル コードは、X-ARR-ClientCert
ヘッダーを取得し、node-forge を使用して base64 エンコード PEM 文字列を証明書オブジェクトに変換して検証します。
import { NextFunction, Request, Response } from 'express';
import { pki, md, asn1 } from 'node-forge';
export class AuthorizationHandler {
public static authorizeClientCertificate(req: Request, res: Response, next: NextFunction): void {
try {
// Get header
const header = req.get('X-ARR-ClientCert');
if (!header) throw new Error('UNAUTHORIZED');
// Convert from PEM to pki.CERT
const pem = `-----BEGIN CERTIFICATE-----${header}-----END CERTIFICATE-----`;
const incomingCert: pki.Certificate = pki.certificateFromPem(pem);
// Validate certificate thumbprint
const fingerPrint = md.sha1.create().update(asn1.toDer(pki.certificateToAsn1(incomingCert)).getBytes()).digest().toHex();
if (fingerPrint.toLowerCase() !== 'abcdef1234567890abcdef1234567890abcdef12') throw new Error('UNAUTHORIZED');
// Validate time validity
const currentDate = new Date();
if (currentDate < incomingCert.validity.notBefore || currentDate > incomingCert.validity.notAfter) throw new Error('UNAUTHORIZED');
// Validate issuer
if (incomingCert.issuer.hash.toLowerCase() !== 'abcdef1234567890abcdef1234567890abcdef12') throw new Error('UNAUTHORIZED');
// Validate subject
if (incomingCert.subject.hash.toLowerCase() !== 'abcdef1234567890abcdef1234567890abcdef12') throw new Error('UNAUTHORIZED');
next();
} catch (e) {
if (e instanceof Error && e.message === 'UNAUTHORIZED') {
res.status(401).send();
} else {
next(e);
}
}
}
}
Java サンプル
次の Java クラスは、X-ARR-ClientCert
から X509Certificate
インスタンスに証明書をエンコードします。 certificateIsValid()
は、証明書のサムプリントがコンストラクターで指定されたものと一致し、その証明書の有効期限が切れていないことを検証します。
import java.io.ByteArrayInputStream;
import java.security.NoSuchAlgorithmException;
import java.security.cert.*;
import java.security.MessageDigest;
import sun.security.provider.X509Factory;
import javax.xml.bind.DatatypeConverter;
import java.util.Base64;
import java.util.Date;
public class ClientCertValidator {
private String thumbprint;
private X509Certificate certificate;
/**
* Constructor.
* @param certificate The certificate from the "X-ARR-ClientCert" HTTP header
* @param thumbprint The thumbprint to check against
* @throws CertificateException If the certificate factory cannot be created.
*/
public ClientCertValidator(String certificate, String thumbprint) throws CertificateException {
certificate = certificate
.replaceAll(X509Factory.BEGIN_CERT, "")
.replaceAll(X509Factory.END_CERT, "");
CertificateFactory cf = CertificateFactory.getInstance("X.509");
byte [] base64Bytes = Base64.getDecoder().decode(certificate);
X509Certificate X509cert = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(base64Bytes));
this.setCertificate(X509cert);
this.setThumbprint(thumbprint);
}
/**
* Check that the certificate's thumbprint matches the one given in the constructor, and that the
* certificate hasn't expired.
* @return True if the certificate's thumbprint matches and hasn't expired. False otherwise.
*/
public boolean certificateIsValid() throws NoSuchAlgorithmException, CertificateEncodingException {
return certificateHasNotExpired() && thumbprintIsValid();
}
/**
* Check certificate's timestamp.
* @return Returns true if the certificate hasn't expired. Returns false if it has expired.
*/
private boolean certificateHasNotExpired() {
Date currentTime = new java.util.Date();
try {
this.getCertificate().checkValidity(currentTime);
} catch (CertificateExpiredException | CertificateNotYetValidException e) {
return false;
}
return true;
}
/**
* Check the certificate's thumbprint matches the given one.
* @return Returns true if the thumbprints match. False otherwise.
*/
private boolean thumbprintIsValid() throws NoSuchAlgorithmException, CertificateEncodingException {
MessageDigest md = MessageDigest.getInstance("SHA-1");
byte[] der = this.getCertificate().getEncoded();
md.update(der);
byte[] digest = md.digest();
String digestHex = DatatypeConverter.printHexBinary(digest);
return digestHex.toLowerCase().equals(this.getThumbprint().toLowerCase());
}
// Getters and setters
public void setThumbprint(String thumbprint) {
this.thumbprint = thumbprint;
}
public String getThumbprint() {
return this.thumbprint;
}
public X509Certificate getCertificate() {
return certificate;
}
public void setCertificate(X509Certificate certificate) {
this.certificate = certificate;
}
}
Python のサンプル
次の Flask と Django の Python のコード サンプルは、有効なクライアント証明書を提示する呼び出し元にのみアクセスを許可するために、ビュー関数で使用できる authorize_certificate
という名前のデコレーターを実装します。 このデコレーターは X-ARR-ClientCert
ヘッダーに PEM 形式の証明書があることを予測し、Python の暗号化パッケージを使用することで、フィンガープリント (サムプリント)、サブジェクトの共通名、発行者の共通名、および開始日と有効期限に基づいて証明書を検証します。 検証が失敗した場合、デコレーターは状態コード 403 (許可されていません) を含む HTTP 応答がクライアントに返されるようにします。
from functools import wraps
from datetime import datetime, timezone
from flask import abort, request
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes
def validate_cert(request):
try:
cert_value = request.headers.get('X-ARR-ClientCert')
if cert_value is None:
return False
cert_data = ''.join(['-----BEGIN CERTIFICATE-----\n', cert_value, '\n-----END CERTIFICATE-----\n',])
cert = x509.load_pem_x509_certificate(cert_data.encode('utf-8'))
fingerprint = cert.fingerprint(hashes.SHA1())
if fingerprint != b'12345678901234567890':
return False
subject = cert.subject
subject_cn = subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
if subject_cn != "contoso.com":
return False
issuer = cert.issuer
issuer_cn = issuer.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
if issuer_cn != "contoso.com":
return False
current_time = datetime.now(timezone.utc)
if current_time < cert.not_valid_before_utc:
return False
if current_time > cert.not_valid_after_utc:
return False
return True
except Exception as e:
# Handle any errors encountered during validation
print(f"Encountered the following error during certificate validation: {e}")
return False
def authorize_certificate(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not validate_cert(request):
abort(403)
return f(*args, **kwargs)
return decorated_function
次のコード スニペットは、Flask のビュー関数でデコレーターを使用する方法を示しています。
@app.route('/hellocert')
@authorize_certificate
def hellocert():
print('Request for hellocert page received')
return render_template('index.html')