Azure App Service에 대한 TLS 상호 인증 구성
다양한 유형의 인증을 사용하여 Azure App Service 앱에 대한 액세스를 제한할 수 있습니다. 이 작업을 수행하는 한 가지 방법은 클라이언트 요청이 TLS/SSL을 초과하고 인증서 요청 시 인증서를 검증하는 것입니다. 이 메커니즘을 TLS(전송 계층 보안) 상호 인증 또는 클라이언트 인증서 인증이라고 합니다. 이 문서에는 클라이언트 인증서 인증을 사용하도록 앱을 설정하는 방법이 나와 있습니다.
참고 항목
앱 코드는 클라이언트 인증서 유효성 검사를 담당합니다. App Service는 이 클라이언트 인증서를 앱에 전달하는 것 외에는 아무 작업도 수행하지 않습니다.
HTTP를 통해 사이트에 액세스하고 HTTPS를 통해서는 액세스하지 않는 경우 클라이언트 인증서가 제공되지 않습니다. 따라서 애플리케이션에 클라이언트 인증서가 필요한 경우 HTTP를 통한 애플리케이션 요청을 허용해서는 안 됩니다.
웹앱 준비
사용자 지정 TLS/SSL 바인딩을 만들거나 App Service 앱에 대한 클라이언트 인증서를 사용하도록 설정하려면 App Service 요금제가 기본, 표준, 프리미엄 또는 격리 계층에 있어야 합니다. 웹앱이 지원되는 가격 책정 계층에 있는지 확인하려면 다음 단계를 따르세요.
웹앱으로 이동
Azure Portal 검색 상자에서 App Services를 찾아 선택합니다.
App Services 페이지에서 웹앱의 이름을 선택합니다.
이제 웹앱의 관리 페이지에 있습니다.
가격 책정 계층 확인
웹앱의 왼쪽 메뉴에 있는 설정 섹션 아래에서 스케일 업(App Service 요금제)을 선택합니다.
웹앱이 사용자 지정 TLS/SSL을 지원하지 않는 F1 또는 D1 계층에 있지 않은지 확인합니다.
강화해야 하는 경우 다음 섹션의 단계를 수행합니다. 그렇지 않은 경우 스케일 업 페이지를 닫고, App Service 요금제 확장 섹션을 건너뜁니다.
App Service 계획 강화
B1, B2, B3 또는 프로덕션 범주의 다른 계층과 같은 유료 계층을 선택합니다.
완료되면 선택을 선택합니다.
다음 메시지가 표시되면 크기 조정 작업이 완료된 것입니다.
클라이언트 인증서 사용하도록 설정
앱에 클라이언트 인증서를 사용하도록 설정하면 선택한 클라이언트 인증서 모드를 선택해야 합니다. 각 모드는 앱이 들어오는 클라이언트 인증서를 처리하는 방법을 정의합니다.
클라이언트 인증서 모드 | 설명 |
---|---|
필수 | 모든 요청에는 클라이언트 인증서가 필요합니다. |
선택 사항 | 요청은 클라이언트 인증서를 사용하거나 사용하지 않을 수 있으며 클라이언트는 기본적으로 인증서를 묻는 메시지가 표시됩니다. 예를 들어 브라우저 클라이언트는 인증을 위해 인증서를 선택하라는 프롬프트를 표시합니다. |
선택적 대화형 사용자 | 요청은 클라이언트 인증서를 사용하거나 사용하지 않을 수 있으며 클라이언트는 기본적으로 인증서를 묻는 메시지가 표시되지 않습니다. 예를 들어 브라우저 클라이언트는 인증을 위해 인증서를 선택하라는 프롬프트를 표시하지 않습니다. |
Azure Portal에서 클라이언트 인증서를 요구하도록 앱을 설정하려면 다음을 수행합니다.
- 앱의 관리 페이지로 이동합니다.
- 앱 관리 페이지의 왼쪽 탐색 영역에서 구성>일반 설정을 선택하세요.
- 선택한 클라이언트 인증서 모드를 선택합니다. 페이지 맨 위에서 저장을 선택합니다.
인증 요구에서 경로 제외
프로그램에 대해 상호 인증을 사용하도록 설정하면 앱 루트의 모든 경로에 액세스할 수 있는 클라이언트 인증서가 필요합니다. 특정 경로에 대한 해당 요구 사항을 제거하려면 애플리케이션 구성의 일부로 제외 경로를 정의하세요.
참고 항목
클라이언트 인증서 제외 경로를 사용하면 앱에 들어오는 요청에 대한 TLS 재협상이 트리거됩니다.
앱 관리 페이지의 왼쪽 탐색 영역에서 구성>일반 설정을 선택하세요.
인증서 제외 경로 옆에 있는 편집 아이콘을 선택합니다.
새 경로를 선택하고, 경로 또는 경로 목록을 구분하거나
;
,,
확인을 선택합니다.페이지 맨 위에서 저장을 선택합니다.
다음 스크린샷에서 시작하는 /public
앱의 경로는 클라이언트 인증서를 요청하지 않습니다. 경로 일치 시 대/소문자를 구분하지 않습니다.
클라이언트 인증서 및 TLS 재협상
일부 클라이언트 인증서 설정의 경우 App Service는 클라이언트 인증서를 묻는 메시지를 표시할지 여부를 알기 전에 요청을 읽으려면 TLS 재협상이 필요합니다. 다음 설정 중에서 TLS 재협상을 트리거합니다.
- "선택적 대화형 사용자" 클라이언트 인증서 모드 사용
- 클라이언트 인증서 제외 경로 사용.
참고 항목
TLS 1.3 및 HTTP 2.0은 TLS 재협상을 지원하지 않습니다. 앱이 TLS 재협상을 사용하는 클라이언트 인증서 설정으로 구성된 경우에는 이러한 프로토콜이 작동하지 않습니다.
TLS 재협상을 사용하지 않도록 설정하고 TLS 핸드셰이크 중에 앱이 클라이언트 인증서를 협상하도록 하려면 다음 모든 설정을 사용하여 앱을 구성해야 합니다.
- 클라이언트 인증서 모드를 "필수" 또는 "선택 사항"으로 설정
- 모든 클라이언트 인증서 제외 경로 제거
TLS 재협상을 사용하여 대용량 파일 업로드
TLS 재협상을 사용하는 클라이언트 인증서 구성은 버퍼 크기 제한으로 인해 큰 파일이 100kb보다 큰 들어오는 요청을 지원할 수 없습니다. 이 시나리오에서는 100kb를 초과하는 POST 또는 PUT 요청이 403 오류로 실패합니다. 이 제한은 구성할 수 없으며 늘릴 수 없습니다.
100kb 제한을 해결하려면 다음 대체 솔루션을 고려하세요.
- TLS 재협상을 사용하지 않도록 설정합니다. 다음 모든 설정으로 앱의 클라이언트 인증서 구성을 업데이트합니다.
- 클라이언트 인증서 모드를 "필수" 또는 "선택 사항"으로 설정
- 모든 클라이언트 인증서 제외 경로 제거
- PUT/POST 요청 전에 HEAD 요청을 보냅니다. HEAD 요청은 클라이언트 인증서를 처리합니다.
- 요청에 헤더
Expect: 100-Continue
를 추가합니다. 이렇게 하면 클라이언트가 버퍼를 우회하는 요청 본문을 보내기 전에 서버가 응답100 Continue
할 때까지 기다립니다.
클라이언트 인증서 액세스
App Service에서 요청 TLS 종료는 프런트 엔드 부하 분산 장치에서 수행됩니다. App Service가 클라이언트 인증서를 사용하도록 설정된 상태로 앱 코드에 요청을 전달하면 클라이언트 인증서와 X-ARR-ClientCert
함께 요청 헤더가 삽입됩니다. App Service는 이 클라이언트 인증서를 앱에 전달하는 것 외에는 아무 작업도 수행하지 않습니다. 앱 코드는 클라이언트 인증서 유효성 검사를 담당합니다.
ASP용.NET의 경우, 클라이언트 인증서는 HttpRequest.ClinentCertificate를 통해 사용할 수 있습니다.
다른 애플리케이션 스택(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를 사용하여 기본 64 인코딩 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')