为 Azure 应用服务配置 TLS 相互身份验证

通过为 Azure 应用服务应用启用不同类型的身份验证可以限制对网站的访问。 若要实现此目的,一种方法是通过 TLS/SSL 发送客户端请求时请求客户端证书,然后验证该证书。 此机制称为 TLS 相互身份验证或客户端证书身份验证。 本文介绍如何将应用设置为使用客户端证书身份验证。

注意

应用代码负责验证客户端证书。 应用服务不会对此客户端证书执行任何操作,而只会将它转发到你的应用。

如果通过 HTTP 而不是 HTTPS 访问站点,不会收到任何客户端证书。 因此,如果应用程序需要客户端证书,则你不应允许通过 HTTP 对应用程序发出请求。

准备 Web 应用

若要为应用服务应用创建自定义 TLS/SSL 绑定或启用客户端证书,应用服务计划必须位于“基本”、“标准”、“高级”或“独立”层级 。 要确保 Web 应用位于受支持的定价层,请执行以下步骤:

转到 Web 应用

  1. Azure 门户的搜索框中,查找并选择“应用服务”。

    Azure 门户中搜索框的屏幕截图,已选择“应用服务”。

  2. 在“应用服务”页上,选择 Web 应用的名称。

    Azure 门户中应用服务页面的屏幕截图,其中显示一个包含所有正在运行的 Web 应用的列表,且突出显示了列表中的第一个应用。

    现在你位于 Web 应用的管理页上。

检查定价层

  1. 在 Web 应用左侧菜单的“设置”部分下,选择“纵向扩展(应用服务计划)”。

    Web 应用菜单“设置”部分的屏幕截图,已选择“纵向扩展(应用服务计划)”。

  2. 确保 Web 应用不在 F1 或 D1 层中,该层不支持自定义 TLS/SSL。

  3. 如果需要增加,请按照下一部分中的步骤进行操作。 否则,请关闭“纵向扩展”页,并跳过纵向扩展应用服务计划部分

纵向扩展应用服务计划

  1. 选择任何非免费层,例如 B1、B2、B3 或“生产”类别中的任何其他层。

  2. 完成后,选择“选择”。

    出现以下消息时,表示缩放操作已完成。

    纵向扩展操作确认消息的屏幕截图。

启用客户端证书

若要将应用设置为需要客户端证书,请执行以下操作:

  1. 从应用的管理页的左侧导航中,选择“配置”>“常规设置”。

  2. 选择偏好的“客户端证书模式”。 在页面顶部选择“保存”。

客户端证书模式 说明
必须 所有请求均需要提供客户端证书。
可选 进行请求时并不强制性要求提供客户端证书。 默认情况下,系统会提示需要客户端证书。 例如,浏览器客户端会弹出提示要求选择用于身份验证的证书。
(可选)交互式用户 进行请求时并不强制性要求提供客户端证书。 默认情况下,系统不会提示需要客户端证书。 例如,浏览器客户端不会弹出提示要求选择用于身份验证的证书。

在本地 Azure CLI 中运行以下命令:

az webapp update --set clientCertEnabled=true --name <app-name> --resource-group <group-name>

使路径不要求身份验证

为应用程序启用相互身份验证时,应用根目录下的所有路径都需要客户端证书才能进行访问。 若要针对特定路径去除此要求,请在应用程序配置中定义排除路径。

  1. 从应用的管理页的左侧导航中,选择“配置”>“常规设置”。

  2. 在“证书排除路径”旁边,选择编辑图标

  3. 选择“新建路径”,指定一个路径或用 ,; 分隔的路径列表,然后选择“确定”

  4. 在页面顶部选择“保存”。

在下面的屏幕截图中,以 /public 开头的应用的任何路径都不会请求客户端证书。 路径匹配不区分大小写。

证书排除路径

访问客户端证书

在应用服务中,请求的 TLS 终止发生在前端负载均衡器上。 在已启用客户端证书的情况下将请求转发到应用代码时,应用服务会注入包含客户端证书的 X-ARR-ClientCert 请求标头。 应用服务不会对此客户端证书执行任何操作,而只会将它转发到你的应用。 应用代码负责验证客户端证书。

对于 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 forwared 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 Azure 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 thumprint 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')