Skip to content

Commit b0e3c2a

Browse files
committed
[feature](tls) Support cert based auth for FE https api
1 parent 5cad485 commit b0e3c2a

File tree

2 files changed

+281
-2
lines changed

2 files changed

+281
-2
lines changed

fe/fe-core/src/main/java/org/apache/doris/httpv2/controller/BaseController.java

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
import org.apache.doris.httpv2.HttpAuthManager;
3131
import org.apache.doris.httpv2.HttpAuthManager.SessionValue;
3232
import org.apache.doris.httpv2.exception.UnauthorizedException;
33+
import org.apache.doris.mysql.authenticate.CertificateAuthVerifier;
34+
import org.apache.doris.mysql.authenticate.CertificateAuthVerifierFactory;
3335
import org.apache.doris.mysql.privilege.PrivPredicate;
3436
import org.apache.doris.qe.ConnectContext;
3537
import org.apache.doris.service.FrontendOptions;
@@ -48,6 +50,7 @@
4850
import org.apache.logging.log4j.Logger;
4951

5052
import java.nio.ByteBuffer;
53+
import java.security.cert.X509Certificate;
5154
import java.util.List;
5255
import java.util.UUID;
5356

@@ -70,7 +73,14 @@ public ActionAuthorizationInfo checkWithCookie(HttpServletRequest request,
7073
if (encodedAuthString != null) {
7174
// If has Authorization header, check auth info
7275
ActionAuthorizationInfo authInfo = getAuthorizationInfo(request);
73-
UserIdentity currentUser = checkPassword(authInfo);
76+
77+
// Try certificate-based authentication first
78+
UserIdentity currentUser = tryCertificateAuth(authInfo, request);
79+
80+
if (currentUser == null) {
81+
// Certificate auth not applicable or password verification still required
82+
currentUser = checkPassword(authInfo);
83+
}
7484

7585
if (Config.isCloudMode() && checkAuth) {
7686
checkInstanceOverdue(currentUser);
@@ -266,6 +276,88 @@ protected UserIdentity checkPassword(ActionAuthorizationInfo authInfo)
266276
return currentUser.get(0);
267277
}
268278

279+
/**
280+
* Extracts client X509 certificate from the HTTPS request.
281+
* The certificate is available via Jetty's SecureRequestCustomizer when client auth is enabled.
282+
*
283+
* @param request the HTTP request
284+
* @return the client's X509 certificate, or null if not available
285+
*/
286+
private X509Certificate getClientCertificate(HttpServletRequest request) {
287+
// Jetty's SecureRequestCustomizer populates this attribute with client certificates
288+
X509Certificate[] certs = (X509Certificate[]) request.getAttribute(
289+
"jakarta.servlet.request.X509Certificate");
290+
if (certs != null && certs.length > 0) {
291+
return certs[0];
292+
}
293+
return null;
294+
}
295+
296+
/**
297+
* Attempts certificate-based authentication for the user.
298+
* This method checks if the user has TLS requirements (e.g., REQUIRE SAN) and verifies
299+
* the client certificate against those requirements.
300+
*
301+
* @param authInfo the authorization info containing username and remote IP
302+
* @param request the HTTP request containing the client certificate
303+
* @return the authenticated UserIdentity if cert auth succeeds and password can be skipped,
304+
* or null if cert auth is not applicable or password verification is still required
305+
* @throws UnauthorizedException if cert verification fails for a user with TLS requirements
306+
*/
307+
private UserIdentity tryCertificateAuth(ActionAuthorizationInfo authInfo, HttpServletRequest request)
308+
throws UnauthorizedException {
309+
CertificateAuthVerifier certVerifier = CertificateAuthVerifierFactory.getInstance();
310+
if (!certVerifier.isEnabled()) {
311+
return null;
312+
}
313+
314+
X509Certificate clientCert = getClientCertificate(request);
315+
316+
// Get matching users without checking password
317+
List<UserIdentity> matchingUsers = Env.getCurrentEnv().getAuth()
318+
.getUserIdentityUncheckPasswd(authInfo.fullUserName, authInfo.remoteIp);
319+
320+
if (matchingUsers.isEmpty()) {
321+
// No matching users found, let password auth handle the error
322+
return null;
323+
}
324+
325+
for (UserIdentity userIdentity : matchingUsers) {
326+
if (!userIdentity.hasTlsRequirements()) {
327+
// No TLS requirements for this user, skip cert verification
328+
continue;
329+
}
330+
331+
// User has TLS requirements - must verify certificate
332+
CertificateAuthVerifier.VerificationResult result =
333+
certVerifier.verify(userIdentity, clientCert);
334+
335+
if (!result.isSuccess()) {
336+
LOG.warn("TLS certificate verification failed for user {}: {}",
337+
userIdentity, result.getErrorMessage());
338+
throw new UnauthorizedException(
339+
"TLS certificate verification failed: " + result.getErrorMessage());
340+
}
341+
342+
// Certificate verification passed
343+
if (certVerifier.shouldSkipPasswordVerification()) {
344+
LOG.info("Certificate-based authentication succeeded for user {}, skipping password verification",
345+
userIdentity);
346+
return userIdentity;
347+
}
348+
349+
// Certificate verified but password verification still required
350+
if (LOG.isDebugEnabled()) {
351+
LOG.debug("Certificate verified for user {}, proceeding with password verification",
352+
userIdentity);
353+
}
354+
return null;
355+
}
356+
357+
// No user with TLS requirements matched, proceed with normal password auth
358+
return null;
359+
}
360+
269361
public ActionAuthorizationInfo getAuthorizationInfo(HttpServletRequest request)
270362
throws UnauthorizedException {
271363
ActionAuthorizationInfo authInfo = new ActionAuthorizationInfo();

regression-test/suites/cloud_p0/tls/test_tls_cert_san_auth.groovy

Lines changed: 188 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,22 @@ suite("test_tls_cert_san_auth") {
117117
// === Cleanup function ===
118118
def cleanup = {
119119
logger.info("Cleaning up test users and restoring config...")
120+
// MySQL protocol test users
120121
try_sql("DROP USER IF EXISTS '${testUserBase}_1'@'%'")
121122
try_sql("DROP USER IF EXISTS '${testUserBase}_2'@'%'")
122123
try_sql("DROP USER IF EXISTS '${testUserBase}_3'@'%'")
123124
try_sql("DROP USER IF EXISTS '${testUserBase}_4'@'%'")
124125
try_sql("DROP USER IF EXISTS '${testUserBase}_5'@'%'")
125126
try_sql("DROP USER IF EXISTS '${testUserBase}_6'@'%'")
126127
try_sql("DROP USER IF EXISTS '${testUserBase}_7'@'%'")
128+
// HTTPS test users
129+
try_sql("DROP USER IF EXISTS '${testUserBase}_http1'@'%'")
130+
try_sql("DROP USER IF EXISTS '${testUserBase}_http2'@'%'")
131+
try_sql("DROP USER IF EXISTS '${testUserBase}_http3'@'%'")
132+
try_sql("DROP USER IF EXISTS '${testUserBase}_http4'@'%'")
133+
try_sql("DROP USER IF EXISTS '${testUserBase}_http5'@'%'")
134+
try_sql("DROP USER IF EXISTS '${testUserBase}_http6'@'%'")
135+
try_sql("DROP USER IF EXISTS '${testUserBase}_http7'@'%'")
127136
try {
128137
sql "ADMIN SET FRONTEND CONFIG ('tls_cert_based_auth_ignore_password' = '${origIgnorePassword}')"
129138
} catch (Exception e) {
@@ -239,7 +248,185 @@ suite("test_tls_cert_san_auth") {
239248
assertTrue(result7c, "Test 7c should succeed: URI SAN match")
240249
logger.info("Test 7c PASSED: URI SAN match works")
241250

242-
logger.info("=== All TLS SAN authentication tests PASSED ===")
251+
logger.info("=== All MySQL protocol TLS SAN authentication tests PASSED ===")
252+
253+
// ==================================================================================
254+
// HTTPS/HTTP Certificate-Based Authentication Tests
255+
// These tests verify that FE's HTTP endpoints also support cert-based auth
256+
// ==================================================================================
257+
logger.info("=== Starting HTTPS certificate-based auth tests ===")
258+
259+
// Get FE HTTP connection info
260+
def feHost = mysqlHost // Same host as MySQL
261+
def httpPort = context.config.feHttpPort ?: "8030"
262+
def httpEndpoint = "/api/bootstrap" // Simple endpoint that requires auth
263+
264+
logger.info("FE HTTPS host: ${feHost}, port: ${httpPort}")
265+
logger.info("Test endpoint: ${httpEndpoint}")
266+
267+
// Helper: Execute curl command and check result
268+
def executeCurlCommand = { String command, boolean expectSuccess = true ->
269+
def cmds = ["/bin/bash", "-c", command]
270+
logger.info("Execute curl: ${command}")
271+
Process p = cmds.execute()
272+
def errMsg = new StringBuilder()
273+
def msg = new StringBuilder()
274+
p.waitForProcessOutput(msg, errMsg)
275+
276+
def output = msg.toString().trim()
277+
def error = errMsg.toString().trim()
278+
logger.info("stdout: ${output}")
279+
logger.info("stderr: ${error}")
280+
logger.info("exitValue: ${p.exitValue()}")
281+
282+
// For curl with -w '%{http_code}', success means HTTP 200
283+
// Authentication failure typically returns 401
284+
if (expectSuccess) {
285+
// Expect HTTP 200 (or 2xx)
286+
return output.startsWith("2") || output == "200"
287+
} else {
288+
// Expect non-2xx (typically 401 Unauthorized)
289+
return !output.startsWith("2") && output != "200"
290+
}
291+
}
292+
293+
// Helper: Build curl command with SAN certificate
294+
def buildCurlWithCert = { String user, String password, String endpoint ->
295+
return "curl -s -o /dev/null -w '%{http_code}' " +
296+
"-u '${user}:${password}' " +
297+
"--cacert ${sanClientCa} " +
298+
"--cert ${sanClientCert} --key ${sanClientKey} " +
299+
"https://${feHost}:${httpPort}${endpoint} 2>&1"
300+
}
301+
302+
// Helper: Build curl command without client certificate
303+
def buildCurlNoCert = { String user, String password, String endpoint ->
304+
return "curl -s -o /dev/null -w '%{http_code}' " +
305+
"-u '${user}:${password}' " +
306+
"--cacert ${sanClientCa} " +
307+
"https://${feHost}:${httpPort}${endpoint} 2>&1"
308+
}
309+
310+
// Helper: Build curl command with no-SAN certificate
311+
def buildCurlWithNoSanCert = { String user, String password, String endpoint ->
312+
return "curl -s -o /dev/null -w '%{http_code}' " +
313+
"-u '${user}:${password}' " +
314+
"--cacert ${sanClientCa} " +
315+
"--cert ${noSanClientCert} --key ${noSanClientKey} " +
316+
"https://${feHost}:${httpPort}${endpoint} 2>&1"
317+
}
318+
319+
// Helper: Build curl command with mismatched SAN certificate
320+
def buildCurlWithMismatchCert = { String user, String password, String endpoint ->
321+
return "curl -s -o /dev/null -w '%{http_code}' " +
322+
"-u '${user}:${password}' " +
323+
"--cacert ${sanClientCa} " +
324+
"--cert ${sanClientCert} --key ${sanClientKey} " +
325+
"https://${feHost}:${httpPort}${endpoint} 2>&1"
326+
}
327+
328+
// Reset config for HTTPS tests
329+
sql "ADMIN SET FRONTEND CONFIG ('tls_cert_based_auth_ignore_password' = 'false')"
330+
331+
// === HTTP Test 1: REQUIRE SAN + matching cert + correct password -> success ===
332+
logger.info("=== HTTP Test 1: REQUIRE SAN + matching cert + correct password ===")
333+
sql "CREATE USER '${testUserBase}_http1'@'%' IDENTIFIED BY '${testPassword}' REQUIRE SAN '${sanEmail}'"
334+
sql "GRANT ADMIN_PRIV ON *.* TO '${testUserBase}_http1'@'%'"
335+
336+
def httpCmd1 = buildCurlWithCert("${testUserBase}_http1", testPassword, httpEndpoint)
337+
def httpResult1 = executeCurlCommand(httpCmd1, true)
338+
assertTrue(httpResult1, "HTTP Test 1 should succeed: matching SAN + correct password")
339+
logger.info("HTTP Test 1 PASSED: HTTPS request successful with matching SAN and password")
340+
341+
// === HTTP Test 2: REQUIRE SAN + matching cert + wrong password -> failure ===
342+
logger.info("=== HTTP Test 2: REQUIRE SAN + matching cert + wrong password ===")
343+
sql "CREATE USER '${testUserBase}_http2'@'%' IDENTIFIED BY '${testPassword}' REQUIRE SAN '${sanEmail}'"
344+
sql "GRANT ADMIN_PRIV ON *.* TO '${testUserBase}_http2'@'%'"
345+
346+
def httpCmd2 = buildCurlWithCert("${testUserBase}_http2", "wrong_password", httpEndpoint)
347+
def httpResult2 = executeCurlCommand(httpCmd2, false)
348+
assertTrue(httpResult2, "HTTP Test 2 should fail: wrong password even with matching SAN")
349+
logger.info("HTTP Test 2 PASSED: HTTPS request rejected with wrong password")
350+
351+
// === HTTP Test 3: REQUIRE SAN + mismatched SAN cert -> failure ===
352+
logger.info("=== HTTP Test 3: REQUIRE SAN + mismatched SAN ===")
353+
sql "CREATE USER '${testUserBase}_http3'@'%' IDENTIFIED BY '${testPassword}' REQUIRE SAN '${sanMismatch}'"
354+
sql "GRANT ADMIN_PRIV ON *.* TO '${testUserBase}_http3'@'%'"
355+
356+
def httpCmd3 = buildCurlWithCert("${testUserBase}_http3", testPassword, httpEndpoint)
357+
def httpResult3 = executeCurlCommand(httpCmd3, false)
358+
assertTrue(httpResult3, "HTTP Test 3 should fail: SAN mismatch")
359+
logger.info("HTTP Test 3 PASSED: HTTPS request rejected with mismatched SAN")
360+
361+
// === HTTP Test 4: REQUIRE SAN + no certificate -> failure ===
362+
// Note: This test depends on tls_verify_mode=verify_fail_if_no_peer_cert
363+
// If the server requires client cert, curl without --cert will fail at TLS handshake
364+
logger.info("=== HTTP Test 4: REQUIRE SAN + no certificate ===")
365+
sql "CREATE USER '${testUserBase}_http4'@'%' IDENTIFIED BY '${testPassword}' REQUIRE SAN '${sanEmail}'"
366+
sql "GRANT ADMIN_PRIV ON *.* TO '${testUserBase}_http4'@'%'"
367+
368+
def httpCmd4 = buildCurlNoCert("${testUserBase}_http4", testPassword, httpEndpoint)
369+
def httpResult4 = executeCurlCommand(httpCmd4, false)
370+
assertTrue(httpResult4, "HTTP Test 4 should fail: no certificate provided for user with REQUIRE SAN")
371+
logger.info("HTTP Test 4 PASSED: HTTPS request rejected without client certificate")
372+
373+
// === HTTP Test 5: REQUIRE SAN + matching cert + ignore_password=true -> success ===
374+
logger.info("=== HTTP Test 5: REQUIRE SAN + ignore_password=true ===")
375+
sql "ADMIN SET FRONTEND CONFIG ('tls_cert_based_auth_ignore_password' = 'true')"
376+
sql "CREATE USER '${testUserBase}_http5'@'%' IDENTIFIED BY '${testPassword}' REQUIRE SAN '${sanEmail}'"
377+
sql "GRANT ADMIN_PRIV ON *.* TO '${testUserBase}_http5'@'%'"
378+
379+
// Use wrong password - should still succeed because ignore_password=true
380+
def httpCmd5 = buildCurlWithCert("${testUserBase}_http5", "any_wrong_password", httpEndpoint)
381+
def httpResult5 = executeCurlCommand(httpCmd5, true)
382+
assertTrue(httpResult5, "HTTP Test 5 should succeed: ignore_password=true allows login with cert only")
383+
logger.info("HTTP Test 5 PASSED: HTTPS request successful with certificate only (password ignored)")
384+
385+
// Reset config
386+
sql "ADMIN SET FRONTEND CONFIG ('tls_cert_based_auth_ignore_password' = 'false')"
387+
388+
// === HTTP Test 6: REQUIRE NONE + no-SAN cert + correct password -> success ===
389+
logger.info("=== HTTP Test 6: REQUIRE NONE + no-SAN cert ===")
390+
sql "CREATE USER '${testUserBase}_http6'@'%' IDENTIFIED BY '${testPassword}'"
391+
sql "GRANT ADMIN_PRIV ON *.* TO '${testUserBase}_http6'@'%'"
392+
393+
def httpCmd6 = buildCurlWithNoSanCert("${testUserBase}_http6", testPassword, httpEndpoint)
394+
def httpResult6 = executeCurlCommand(httpCmd6, true)
395+
assertTrue(httpResult6, "HTTP Test 6 should succeed: no TLS requirements, password auth works")
396+
logger.info("HTTP Test 6 PASSED: HTTPS request successful for user without TLS requirements")
397+
398+
// === HTTP Test 7: ALTER USER add/remove REQUIRE SAN for HTTP ===
399+
logger.info("=== HTTP Test 7: ALTER USER add/remove REQUIRE SAN for HTTP ===")
400+
sql "CREATE USER '${testUserBase}_http7'@'%' IDENTIFIED BY '${testPassword}' REQUIRE SAN '${sanEmail}'"
401+
sql "GRANT ADMIN_PRIV ON *.* TO '${testUserBase}_http7'@'%'"
402+
403+
// First verify it works with matching cert
404+
def httpCmd7a = buildCurlWithCert("${testUserBase}_http7", testPassword, httpEndpoint)
405+
def httpResult7a = executeCurlCommand(httpCmd7a, true)
406+
assertTrue(httpResult7a, "HTTP Test 7a should succeed: initial REQUIRE SAN with matching cert")
407+
logger.info("HTTP Test 7a PASSED: REQUIRE SAN works with matching cert via HTTPS")
408+
409+
// Remove REQUIRE SAN
410+
sql "ALTER USER '${testUserBase}_http7'@'%' REQUIRE NONE"
411+
412+
// Now should work with no-SAN certificate
413+
def httpCmd7b = buildCurlWithNoSanCert("${testUserBase}_http7", testPassword, httpEndpoint)
414+
def httpResult7b = executeCurlCommand(httpCmd7b, true)
415+
assertTrue(httpResult7b, "HTTP Test 7b should succeed: REQUIRE NONE allows any cert")
416+
logger.info("HTTP Test 7b PASSED: REQUIRE NONE works with no-SAN certificate via HTTPS")
417+
418+
// Add back REQUIRE SAN with different SAN type (DNS)
419+
sql "ALTER USER '${testUserBase}_http7'@'%' REQUIRE SAN '${sanDns}'"
420+
421+
// Now should work with DNS SAN from the same cert
422+
def httpCmd7c = buildCurlWithCert("${testUserBase}_http7", testPassword, httpEndpoint)
423+
def httpResult7c = executeCurlCommand(httpCmd7c, true)
424+
assertTrue(httpResult7c, "HTTP Test 7c should succeed: DNS SAN match")
425+
logger.info("HTTP Test 7c PASSED: REQUIRE SAN (DNS) works via HTTPS")
426+
427+
logger.info("=== All HTTPS certificate-based auth tests PASSED ===")
428+
429+
logger.info("=== All TLS SAN authentication tests (MySQL + HTTPS) PASSED ===")
243430

244431
} finally {
245432
cleanup()

0 commit comments

Comments
 (0)