This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new 3737c679da5 branch-4.1: [fix](fe) Apply the same checks to HTTP Basic
and cookie requests on /rest/v1 (#68635)
3737c679da5 is described below
commit 3737c679da53833d61ea07a8e5501848068a4eaf
Author: Calvin Kirs <[email protected]>
AuthorDate: Tue Sep 29 21:46:39 2026 +0800
branch-4.1: [fix](fe) Apply the same checks to HTTP Basic and cookie
requests on /rest/v1 (#68635)
https://github.com/apache/doris/pull/68549
---
.../doris/httpv2/controller/BaseController.java | 9 +-
.../doris/httpv2/controller/LoginController.java | 11 +-
.../controller/BaseControllerBasicAuthTest.java | 188 +++++++++++++++++++++
.../suites/auth_p0/test_http_rest_v1_auth.groovy | 90 ++++++++++
4 files changed, 295 insertions(+), 3 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/controller/BaseController.java
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/controller/BaseController.java
index 5a2b97e44d8..7e09ea0eaf4 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/controller/BaseController.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/controller/BaseController.java
@@ -77,8 +77,13 @@ public class BaseController {
ActionAuthorizationInfo authInfo = getAuthorizationInfo(request);
UserIdentity currentUser = checkPassword(authInfo, request);
- if (Config.isCloudMode() && checkAuth) {
- checkInstanceOverdue(currentUser);
+ // The cookie branch below requires ADMIN_OR_NODE whenever
checkAuth is set, in every deployment
+ // mode, so this branch must as well: which of the two ways a
caller authenticates must not change
+ // what it is allowed to reach. Only the overdue fence is specific
to cloud mode.
+ if (checkAuth) {
+ if (Config.isCloudMode()) {
+ checkInstanceOverdue(currentUser);
+ }
checkGlobalAuth(currentUser, PrivPredicate.ADMIN_OR_NODE);
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/controller/LoginController.java
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/controller/LoginController.java
index fbf0a16b02b..9d7ad312596 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/controller/LoginController.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/controller/LoginController.java
@@ -17,6 +17,9 @@
package org.apache.doris.httpv2.controller;
+import org.apache.doris.common.Config;
+import org.apache.doris.qe.ConnectContext;
+
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -32,7 +35,13 @@ public class LoginController extends BaseController {
@RequestMapping(path = "/login", method = RequestMethod.POST)
public Object login(HttpServletRequest request, HttpServletResponse
response) {
- checkAuthWithCookie(request, response);
+ // Login only establishes who the caller is. What the account may
reach is decided on each /rest/v1 request
+ // that follows, where the session issued here is checked for the
required privilege; this lets the UI
+ // tell an account that lacks it apart from a failed sign-in.
+ checkWithCookie(request, response, false);
+ if (Config.isCloudMode()) {
+
checkInstanceOverdue(ConnectContext.get().getCurrentUserIdentity());
+ }
Map<String, Object> msg = new HashMap<>();
msg.put("code", 200);
msg.put("msg", "Login success!");
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/httpv2/controller/BaseControllerBasicAuthTest.java
b/fe/fe-core/src/test/java/org/apache/doris/httpv2/controller/BaseControllerBasicAuthTest.java
new file mode 100644
index 00000000000..7febfcff270
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/httpv2/controller/BaseControllerBasicAuthTest.java
@@ -0,0 +1,188 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.httpv2.controller;
+
+import org.apache.doris.analysis.UserIdentity;
+import org.apache.doris.common.Config;
+import org.apache.doris.httpv2.HttpAuthManager.SessionValue;
+import
org.apache.doris.httpv2.controller.BaseController.ActionAuthorizationInfo;
+import org.apache.doris.httpv2.exception.UnauthorizedException;
+import org.apache.doris.httpv2.interceptor.AuthInterceptor;
+import org.apache.doris.mysql.privilege.PrivPredicate;
+import org.apache.doris.qe.ConnectContext;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Proxy;
+import java.util.Map;
+
+/**
+ * HTTP Basic requests to the /rest/v1 surface, outside cloud mode.
+ */
+class BaseControllerBasicAuthTest {
+ private final UserIdentity analyst =
UserIdentity.createAnalyzedUserIdentWithIp("analyst", "%");
+
+ private PrivPredicate checkedPredicate;
+ private SessionValue issuedSession;
+
+ @BeforeEach
+ void setUp() {
+ Assertions.assertFalse(Config.isCloudMode());
+ checkedPredicate = null;
+ issuedSession = null;
+ }
+
+ @AfterEach
+ void tearDown() {
+ ConnectContext.remove();
+ }
+
+ @Test
+ void rejectsBasicAuthenticationWithoutPrivilege() {
+ AuthInterceptor interceptor = interceptor(false);
+
+ Assertions.assertThrows(UnauthorizedException.class,
+ () -> interceptor.preHandle(request("/rest/v1/system"),
response(), new Object()));
+ Assertions.assertEquals(PrivPredicate.ADMIN_OR_NODE, checkedPredicate);
+ Assertions.assertNull(issuedSession);
+ }
+
+ @Test
+ void acceptsBasicAuthenticationWithPrivilege() {
+ AuthInterceptor interceptor = interceptor(true);
+
+
Assertions.assertTrue(interceptor.preHandle(request("/rest/v1/system"),
response(), new Object()));
+ Assertions.assertEquals(PrivPredicate.ADMIN_OR_NODE, checkedPredicate);
+ Assertions.assertEquals(analyst, issuedSession.currentUser);
+ }
+
+ @Test
+ void loginAuthenticatesAnAccountWithoutPrivilege() {
+ // The UI tells an account that lacks the privilege apart from a
failed sign-in, so login itself only
+ // authenticates; the privilege is checked on the requests that follow.
+ LoginController controller = new LoginController() {
+ @Override
+ public ActionAuthorizationInfo
getAuthorizationInfo(HttpServletRequest request) {
+ return authorizationInfo();
+ }
+
+ @Override
+ protected UserIdentity checkPassword(ActionAuthorizationInfo
authInfo, HttpServletRequest request) {
+ return analyst;
+ }
+
+ @Override
+ protected void checkGlobalAuth(UserIdentity currentUser,
PrivPredicate predicate) {
+ checkedPredicate = predicate;
+ throw new UnauthorizedException("Access denied");
+ }
+
+ @Override
+ protected void addSession(HttpServletRequest request,
HttpServletResponse response,
+ SessionValue value) {
+ issuedSession = value;
+ }
+ };
+
+ @SuppressWarnings("unchecked")
+ Map<String, Object> result = (Map<String, Object>)
controller.login(request("/rest/v1/login"), response());
+ Assertions.assertEquals(200, result.get("code"));
+ Assertions.assertNull(checkedPredicate);
+ Assertions.assertEquals(analyst, issuedSession.currentUser);
+ }
+
+ private AuthInterceptor interceptor(boolean privileged) {
+ return new AuthInterceptor() {
+ @Override
+ public ActionAuthorizationInfo
getAuthorizationInfo(HttpServletRequest request) {
+ return authorizationInfo();
+ }
+
+ @Override
+ protected UserIdentity checkPassword(ActionAuthorizationInfo
authInfo, HttpServletRequest request) {
+ return analyst;
+ }
+
+ @Override
+ protected void checkGlobalAuth(UserIdentity currentUser,
PrivPredicate predicate) {
+ checkedPredicate = predicate;
+ Assertions.assertEquals(analyst, currentUser);
+ if (!privileged) {
+ throw new UnauthorizedException("Access denied");
+ }
+ }
+
+ @Override
+ protected void addSession(HttpServletRequest request,
HttpServletResponse response,
+ SessionValue value) {
+ issuedSession = value;
+ }
+ };
+ }
+
+ private ActionAuthorizationInfo authorizationInfo() {
+ ActionAuthorizationInfo authInfo = new ActionAuthorizationInfo();
+ authInfo.fullUserName = analyst.getQualifiedUser();
+ authInfo.password = "secret";
+ authInfo.remoteIp = "127.0.0.1";
+ return authInfo;
+ }
+
+ private HttpServletRequest request(String requestUri) {
+ return (HttpServletRequest) Proxy.newProxyInstance(
+ HttpServletRequest.class.getClassLoader(),
+ new Class<?>[] {HttpServletRequest.class},
+ (proxy, method, args) -> {
+ switch (method.getName()) {
+ case "getMethod":
+ return "GET";
+ case "getRequestURI":
+ return requestUri;
+ case "getHeader":
+ return "Authorization".equals(args[0]) ? "Basic
ignored-by-test" : null;
+ default:
+ return defaultValue(method.getReturnType());
+ }
+ });
+ }
+
+ private HttpServletResponse response() {
+ return (HttpServletResponse) Proxy.newProxyInstance(
+ HttpServletResponse.class.getClassLoader(),
+ new Class<?>[] {HttpServletResponse.class},
+ (proxy, method, args) -> defaultValue(method.getReturnType()));
+ }
+
+ private Object defaultValue(Class<?> returnType) {
+ if (!returnType.isPrimitive()) {
+ return null;
+ }
+ if (returnType == boolean.class) {
+ return false;
+ }
+ if (returnType == char.class) {
+ return '\0';
+ }
+ return 0;
+ }
+}
diff --git a/regression-test/suites/auth_p0/test_http_rest_v1_auth.groovy
b/regression-test/suites/auth_p0/test_http_rest_v1_auth.groovy
new file mode 100644
index 00000000000..f6cdd0444c8
--- /dev/null
+++ b/regression-test/suites/auth_p0/test_http_rest_v1_auth.groovy
@@ -0,0 +1,90 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// The /rest/v1 endpoints behind the Web UI require ADMIN_OR_NODE. HTTP Basic
requests are checked for it
+// just as session-cookie requests are, in every deployment mode.
/rest/v1/login itself only authenticates,
+// so the UI can tell an account without the privilege apart from a failed
sign-in.
+suite("test_http_rest_v1_auth", "p0,auth") {
+ String user = "test_http_rest_v1_auth_user"
+ String pwd = 'C123_567p'
+ try_sql("DROP USER ${user}")
+ sql """CREATE USER '${user}' IDENTIFIED BY '${pwd}'"""
+
+ def uris = [
+ "/rest/v1/system?path=/",
+ "/rest/v1/config/fe",
+ "/rest/v1/session",
+ "/rest/v1/query_profile",
+ "/rest/v1/log",
+ "/rest/v1/ha"
+ ]
+
+ def getRestV1 = { uriPath, checkFunc ->
+ httpTest {
+ basicAuthorization "${user}", "${pwd}"
+ endpoint "${context.config.feHttpAddress}"
+ uri uriPath
+ op "get"
+ check checkFunc
+ }
+ }
+
+ def login = { checkFunc ->
+ httpTest {
+ basicAuthorization "${user}", "${pwd}"
+ endpoint "${context.config.feHttpAddress}"
+ uri "/rest/v1/login"
+ op "post"
+ body "{}"
+ check checkFunc
+ }
+ }
+
+ uris.each { uriPath ->
+ getRestV1.call(uriPath) {
+ respCode, body ->
+ log.info("${uriPath} (no privilege) body:${body}")
+ assertEquals(200, respCode)
+ assertEquals(401, parseJson(body).code)
+ }
+ }
+
+ login.call {
+ respCode, body ->
+ log.info("login (no privilege) body:${body}")
+ assertEquals(200, respCode)
+ assertEquals(200, parseJson(body).code)
+ }
+
+ sql """GRANT 'admin' TO '${user}'"""
+
+ uris.each { uriPath ->
+ getRestV1.call(uriPath) {
+ respCode, body ->
+ log.info("${uriPath} (admin) body:${body}")
+ assertEquals(200, respCode)
+ assertEquals(0, parseJson(body).code)
+ }
+ }
+
+ login.call {
+ respCode, body ->
+ log.info("login (admin) body:${body}")
+ assertEquals(200, respCode)
+ assertEquals(200, parseJson(body).code)
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]