Skip to content

Commit 7a477a7

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

File tree

2 files changed

+283
-2
lines changed

2 files changed

+283
-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: 190 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,187 @@ 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 feHostIp = mysqlHost // Actual IP to connect to
261+
def httpPort = context.config.feHttpPort ?: "8030"
262+
def httpEndpoint = "/api/bootstrap" // Simple endpoint that requires auth
263+
264+
logger.info("FE HTTPS host IP: ${feHostIp}, 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+
// Use --resolve to map localhost to actual IP, avoiding SNI issues with IP addresses
295+
// Use -k to skip server certificate verification (we're testing client cert auth)
296+
def buildCurlWithCert = { String user, String password, String endpoint ->
297+
return "curl -s -k -o /dev/null -w '%{http_code}' " +
298+
"--resolve 'localhost:${httpPort}:${feHostIp}' " +
299+
"-u '${user}:${password}' " +
300+
"--cert ${sanClientCert} --key ${sanClientKey} " +
301+
"https://localhost:${httpPort}${endpoint} 2>&1"
302+
}
303+
304+
// Helper: Build curl command without client certificate
305+
def buildCurlNoCert = { String user, String password, String endpoint ->
306+
return "curl -s -k -o /dev/null -w '%{http_code}' " +
307+
"--resolve 'localhost:${httpPort}:${feHostIp}' " +
308+
"-u '${user}:${password}' " +
309+
"https://localhost:${httpPort}${endpoint} 2>&1"
310+
}
311+
312+
// Helper: Build curl command with no-SAN certificate
313+
def buildCurlWithNoSanCert = { String user, String password, String endpoint ->
314+
return "curl -s -k -o /dev/null -w '%{http_code}' " +
315+
"--resolve 'localhost:${httpPort}:${feHostIp}' " +
316+
"-u '${user}:${password}' " +
317+
"--cert ${noSanClientCert} --key ${noSanClientKey} " +
318+
"https://localhost:${httpPort}${endpoint} 2>&1"
319+
}
320+
321+
// Helper: Build curl command with mismatched SAN certificate (not currently used)
322+
def buildCurlWithMismatchCert = { String user, String password, String endpoint ->
323+
return "curl -s -k -o /dev/null -w '%{http_code}' " +
324+
"--resolve 'localhost:${httpPort}:${feHostIp}' " +
325+
"-u '${user}:${password}' " +
326+
"--cert ${sanClientCert} --key ${sanClientKey} " +
327+
"https://localhost:${httpPort}${endpoint} 2>&1"
328+
}
329+
330+
// Reset config for HTTPS tests
331+
sql "ADMIN SET FRONTEND CONFIG ('tls_cert_based_auth_ignore_password' = 'false')"
332+
333+
// === HTTP Test 1: REQUIRE SAN + matching cert + correct password -> success ===
334+
logger.info("=== HTTP Test 1: REQUIRE SAN + matching cert + correct password ===")
335+
sql "CREATE USER '${testUserBase}_http1'@'%' IDENTIFIED BY '${testPassword}' REQUIRE SAN '${sanEmail}'"
336+
sql "GRANT ADMIN_PRIV ON *.*.* TO '${testUserBase}_http1'@'%'"
337+
338+
def httpCmd1 = buildCurlWithCert("${testUserBase}_http1", testPassword, httpEndpoint)
339+
def httpResult1 = executeCurlCommand(httpCmd1, true)
340+
assertTrue(httpResult1, "HTTP Test 1 should succeed: matching SAN + correct password")
341+
logger.info("HTTP Test 1 PASSED: HTTPS request successful with matching SAN and password")
342+
343+
// === HTTP Test 2: REQUIRE SAN + matching cert + wrong password -> failure ===
344+
logger.info("=== HTTP Test 2: REQUIRE SAN + matching cert + wrong password ===")
345+
sql "CREATE USER '${testUserBase}_http2'@'%' IDENTIFIED BY '${testPassword}' REQUIRE SAN '${sanEmail}'"
346+
sql "GRANT ADMIN_PRIV ON *.*.* TO '${testUserBase}_http2'@'%'"
347+
348+
def httpCmd2 = buildCurlWithCert("${testUserBase}_http2", "wrong_password", httpEndpoint)
349+
def httpResult2 = executeCurlCommand(httpCmd2, false)
350+
assertTrue(httpResult2, "HTTP Test 2 should fail: wrong password even with matching SAN")
351+
logger.info("HTTP Test 2 PASSED: HTTPS request rejected with wrong password")
352+
353+
// === HTTP Test 3: REQUIRE SAN + mismatched SAN cert -> failure ===
354+
logger.info("=== HTTP Test 3: REQUIRE SAN + mismatched SAN ===")
355+
sql "CREATE USER '${testUserBase}_http3'@'%' IDENTIFIED BY '${testPassword}' REQUIRE SAN '${sanMismatch}'"
356+
sql "GRANT ADMIN_PRIV ON *.*.* TO '${testUserBase}_http3'@'%'"
357+
358+
def httpCmd3 = buildCurlWithCert("${testUserBase}_http3", testPassword, httpEndpoint)
359+
def httpResult3 = executeCurlCommand(httpCmd3, false)
360+
assertTrue(httpResult3, "HTTP Test 3 should fail: SAN mismatch")
361+
logger.info("HTTP Test 3 PASSED: HTTPS request rejected with mismatched SAN")
362+
363+
// === HTTP Test 4: REQUIRE SAN + no certificate -> failure ===
364+
// Note: This test depends on tls_verify_mode=verify_fail_if_no_peer_cert
365+
// If the server requires client cert, curl without --cert will fail at TLS handshake
366+
logger.info("=== HTTP Test 4: REQUIRE SAN + no certificate ===")
367+
sql "CREATE USER '${testUserBase}_http4'@'%' IDENTIFIED BY '${testPassword}' REQUIRE SAN '${sanEmail}'"
368+
sql "GRANT ADMIN_PRIV ON *.*.* TO '${testUserBase}_http4'@'%'"
369+
370+
def httpCmd4 = buildCurlNoCert("${testUserBase}_http4", testPassword, httpEndpoint)
371+
def httpResult4 = executeCurlCommand(httpCmd4, false)
372+
assertTrue(httpResult4, "HTTP Test 4 should fail: no certificate provided for user with REQUIRE SAN")
373+
logger.info("HTTP Test 4 PASSED: HTTPS request rejected without client certificate")
374+
375+
// === HTTP Test 5: REQUIRE SAN + matching cert + ignore_password=true -> success ===
376+
logger.info("=== HTTP Test 5: REQUIRE SAN + ignore_password=true ===")
377+
sql "ADMIN SET FRONTEND CONFIG ('tls_cert_based_auth_ignore_password' = 'true')"
378+
sql "CREATE USER '${testUserBase}_http5'@'%' IDENTIFIED BY '${testPassword}' REQUIRE SAN '${sanEmail}'"
379+
sql "GRANT ADMIN_PRIV ON *.*.* TO '${testUserBase}_http5'@'%'"
380+
381+
// Use wrong password - should still succeed because ignore_password=true
382+
def httpCmd5 = buildCurlWithCert("${testUserBase}_http5", "any_wrong_password", httpEndpoint)
383+
def httpResult5 = executeCurlCommand(httpCmd5, true)
384+
assertTrue(httpResult5, "HTTP Test 5 should succeed: ignore_password=true allows login with cert only")
385+
logger.info("HTTP Test 5 PASSED: HTTPS request successful with certificate only (password ignored)")
386+
387+
// Reset config
388+
sql "ADMIN SET FRONTEND CONFIG ('tls_cert_based_auth_ignore_password' = 'false')"
389+
390+
// === HTTP Test 6: REQUIRE NONE + no-SAN cert + correct password -> success ===
391+
logger.info("=== HTTP Test 6: REQUIRE NONE + no-SAN cert ===")
392+
sql "CREATE USER '${testUserBase}_http6'@'%' IDENTIFIED BY '${testPassword}'"
393+
sql "GRANT ADMIN_PRIV ON *.*.* TO '${testUserBase}_http6'@'%'"
394+
395+
def httpCmd6 = buildCurlWithNoSanCert("${testUserBase}_http6", testPassword, httpEndpoint)
396+
def httpResult6 = executeCurlCommand(httpCmd6, true)
397+
assertTrue(httpResult6, "HTTP Test 6 should succeed: no TLS requirements, password auth works")
398+
logger.info("HTTP Test 6 PASSED: HTTPS request successful for user without TLS requirements")
399+
400+
// === HTTP Test 7: ALTER USER add/remove REQUIRE SAN for HTTP ===
401+
logger.info("=== HTTP Test 7: ALTER USER add/remove REQUIRE SAN for HTTP ===")
402+
sql "CREATE USER '${testUserBase}_http7'@'%' IDENTIFIED BY '${testPassword}' REQUIRE SAN '${sanEmail}'"
403+
sql "GRANT ADMIN_PRIV ON *.*.* TO '${testUserBase}_http7'@'%'"
404+
405+
// First verify it works with matching cert
406+
def httpCmd7a = buildCurlWithCert("${testUserBase}_http7", testPassword, httpEndpoint)
407+
def httpResult7a = executeCurlCommand(httpCmd7a, true)
408+
assertTrue(httpResult7a, "HTTP Test 7a should succeed: initial REQUIRE SAN with matching cert")
409+
logger.info("HTTP Test 7a PASSED: REQUIRE SAN works with matching cert via HTTPS")
410+
411+
// Remove REQUIRE SAN
412+
sql "ALTER USER '${testUserBase}_http7'@'%' REQUIRE NONE"
413+
414+
// Now should work with no-SAN certificate
415+
def httpCmd7b = buildCurlWithNoSanCert("${testUserBase}_http7", testPassword, httpEndpoint)
416+
def httpResult7b = executeCurlCommand(httpCmd7b, true)
417+
assertTrue(httpResult7b, "HTTP Test 7b should succeed: REQUIRE NONE allows any cert")
418+
logger.info("HTTP Test 7b PASSED: REQUIRE NONE works with no-SAN certificate via HTTPS")
419+
420+
// Add back REQUIRE SAN with different SAN type (DNS)
421+
sql "ALTER USER '${testUserBase}_http7'@'%' REQUIRE SAN '${sanDns}'"
422+
423+
// Now should work with DNS SAN from the same cert
424+
def httpCmd7c = buildCurlWithCert("${testUserBase}_http7", testPassword, httpEndpoint)
425+
def httpResult7c = executeCurlCommand(httpCmd7c, true)
426+
assertTrue(httpResult7c, "HTTP Test 7c should succeed: DNS SAN match")
427+
logger.info("HTTP Test 7c PASSED: REQUIRE SAN (DNS) works via HTTPS")
428+
429+
logger.info("=== All HTTPS certificate-based auth tests PASSED ===")
430+
431+
logger.info("=== All TLS SAN authentication tests (MySQL + HTTPS) PASSED ===")
243432

244433
} finally {
245434
cleanup()

0 commit comments

Comments
 (0)