This is an automated email from the ASF dual-hosted git repository.
pradeepagrawal8184 pushed a commit to branch ranger-2.10
in repository https://gitbox.apache.org/repos/asf/ranger.git
The following commit(s) were added to refs/heads/ranger-2.10 by this push:
new eac1031be RANGER-5749: Limit concurrent Ranger Admin UI sessions per
user (#1235)
eac1031be is described below
commit eac1031bef83818221d0dff99cb26566c1333986
Author: eoinmcdonnell113 <[email protected]>
AuthorDate: Thu Oct 15 05:53:43 2026 -0400
RANGER-5749: Limit concurrent Ranger Admin UI sessions per user (#1235)
Cherry-pick of 1641c7d75 (#1200) from master.
https://issues.apache.org/jira/browse/RANGER-5749
(cherry picked from commit 1641c7d754130d99d4017f1cd6f4f2cc523a9f1e)
Co-authored-by: eoinmcdonnell113 <[email protected]>
---
.../java/org/apache/ranger/biz/SessionMgr.java | 210 ++++++++++-
.../web/filter/RangerKRBAuthenticationFilter.java | 47 ++-
.../conf.dist/ranger-admin-default-site.xml | 13 +
security-admin/src/main/webapp/WEB-INF/web.xml | 3 +
.../java/org/apache/ranger/biz/TestSessionMgr.java | 408 +++++++++++++++++++++
.../filter/TestRangerKRBAuthenticationFilter.java | 126 +++++++
6 files changed, 803 insertions(+), 4 deletions(-)
diff --git a/security-admin/src/main/java/org/apache/ranger/biz/SessionMgr.java
b/security-admin/src/main/java/org/apache/ranger/biz/SessionMgr.java
index 56f405a9d..c31ed4e51 100644
--- a/security-admin/src/main/java/org/apache/ranger/biz/SessionMgr.java
+++ b/security-admin/src/main/java/org/apache/ranger/biz/SessionMgr.java
@@ -21,10 +21,13 @@
import java.util.ArrayList;
import java.util.Calendar;
+import java.util.Comparator;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CopyOnWriteArraySet;
@@ -75,6 +78,14 @@ public class SessionMgr {
static final Logger logger = LoggerFactory.getLogger(SessionMgr.class);
+ public static final String PROP_SESSION_LIMIT_CONCURRENCY =
"ranger.session.limit.concurrency";
+ public static final String SESSION_ATTR_CONCURRENT_EXPIRED =
"RANGER_CONCURRENT_SESSION_EXPIRED";
+ public static final String SESSION_ATTR_CONCURRENT_EXPIRED_SSO =
"RANGER_CONCURRENT_SESSION_EXPIRED_SSO";
+ public static final String SESSION_ATTR_DOWNLOAD_ONLY =
"RANGER_SESSION_DOWNLOAD_ONLY";
+ public static final String SESSION_ATTR_NON_UI =
"RANGER_SESSION_NON_UI";
+ private static final String DEFAULT_BROWSER_USER_AGENTS =
"Mozilla,Opera,Chrome";
+ private static final ConcurrentHashMap<String, Object>
CONCURRENT_SESSION_LOCKS = new ConcurrentHashMap<>();
+
@Autowired
RESTErrorUtil restErrorUtil;
@@ -158,7 +169,7 @@ public UserSessionBase processSuccessLogin(int authType,
String userAgent,
gjAuthSession =
storeAuthSession(gjAuthSession);
session.setAttribute("auditLoginId", gjAuthSession.getId());
}
- else if
(!StringUtils.isEmpty(httpRequest.getRequestURI()) &&
!(httpRequest.getRequestURI().contains("/secure/policies/download/") ||
httpRequest.getRequestURI().contains("/secure/download/"))){
+ else if
(!StringUtils.isEmpty(httpRequest.getRequestURI()) &&
!isPluginOrSecureDownloadRequest(httpRequest.getRequestURI())){
gjAuthSession =
storeAuthSession(gjAuthSession);
session.setAttribute("auditLoginId", gjAuthSession.getId());
}else if
(StringUtils.isEmpty(httpRequest.getRequestURI())){
@@ -211,6 +222,24 @@ else if (!StringUtils.isEmpty(httpRequest.getRequestURI())
&& !(httpRequest.getR
}
}
+ if (session != null) {
+ if
(isPluginOrSecureDownloadRequest(httpRequest.getRequestURI())) {
+ try {
+
session.setAttribute(SESSION_ATTR_DOWNLOAD_ONLY, Boolean.TRUE);
+ } catch (IllegalStateException e) {
+ logger.debug("Could not mark
download-only session", e);
+ }
+ } else if
(!isBrowserUserAgent(resolveUserAgent(userAgent, httpRequest))) {
+ try {
+
session.setAttribute(SESSION_ATTR_NON_UI, Boolean.TRUE);
+ } catch (IllegalStateException e) {
+ logger.debug("Could not mark
non-UI session", e);
+ }
+ } else {
+
enforceConcurrentSessionLimit(currentLoginId, session);
+ }
+ }
+
}
return userSession;
@@ -383,6 +412,185 @@ public Date getLastSuccessLoginAuthTimeByUserId(String
loginId) {
return null;
}
+ public static boolean isConcurrentSessionExpired(HttpSession session) {
+ if (session == null) {
+ return false;
+ }
+
+ try {
+ return
Boolean.TRUE.equals(session.getAttribute(SESSION_ATTR_CONCURRENT_EXPIRED));
+ } catch (IllegalStateException e) {
+ return false;
+ }
+ }
+
+ public static boolean isConcurrentSessionExpiredSso(HttpSession
session) {
+ if (session == null) {
+ return false;
+ }
+
+ try {
+ return
Boolean.TRUE.equals(session.getAttribute(SESSION_ATTR_CONCURRENT_EXPIRED_SSO));
+ } catch (IllegalStateException e) {
+ return false;
+ }
+ }
+
+ /**
+ * When {@code ranger.session.limit.concurrency} is exceeded, expire
the oldest UI sessions
+ * so the new login succeeds. SSO sessions are marked expired for Knox
logout redirect.
+ * The count is taken from this JVM's in-memory session list, not
cluster-wide.
+ * Find-and-expire is serialized per loginId so two concurrent UI
logins for the same user
+ * cannot both observe a count under the limit.
+ */
+ protected void enforceConcurrentSessionLimit(String loginId,
HttpSession currentSession) {
+ int limit =
PropertiesUtil.getIntProperty(PROP_SESSION_LIMIT_CONCURRENCY, 0);
+
+ if (limit <= 0 || StringUtils.isBlank(loginId) ||
currentSession == null) {
+ return;
+ }
+
+ Object lock =
CONCURRENT_SESSION_LOCKS.computeIfAbsent(loginId.toLowerCase(Locale.ROOT), id
-> new Object());
+
+ synchronized (lock) {
+ List<HttpSession> otherSessions =
findActiveUiSessionsForUser(loginId, currentSession);
+
+ if (otherSessions.size() < limit) {
+ return;
+ }
+
+ otherSessions.sort(Comparator.comparingLong(session -> {
+ try {
+ return session.getCreationTime();
+ } catch (IllegalStateException e) {
+ return 0L;
+ }
+ }));
+
+ int toExpire = otherSessions.size() - limit + 1;
+
+ logger.info("Concurrent session limit {} exceeded for
user {}; expiring {} older session(s)", limit, loginId, toExpire);
+
+ for (int i = 0; i < toExpire; i++) {
+ expireConcurrentSession(otherSessions.get(i));
+ }
+ }
+ }
+
+ /**
+ * Plugin and secure download URLs. Used both to skip x_auth_sess rows
(unless
+ * {@code ranger.downloadpolicy.session.log.enabled} is true) and to
exclude
+ * those sessions from the UI concurrent-session quota.
+ */
+ static boolean isPluginOrSecureDownloadRequest(String uri) {
+ if (StringUtils.isEmpty(uri)) {
+ return false;
+ }
+
+ return uri.contains("/secure/policies/download/")
+ || uri.contains("/secure/download/")
+ || uri.contains("/plugins/policies/download/")
+ || uri.contains("/tags/download/")
+ || uri.contains("/roles/download/")
+ || uri.contains("/xusers/download/")
+ || uri.contains("/gds/download/");
+ }
+
+ static boolean isBrowserUserAgent(String userAgent) {
+ if (StringUtils.isBlank(userAgent)) {
+ return false;
+ }
+
+ String agents =
PropertiesUtil.getProperty("ranger.krb.browser-useragents-regex",
DEFAULT_BROWSER_USER_AGENTS);
+
+ if (StringUtils.isBlank(agents)) {
+ agents = DEFAULT_BROWSER_USER_AGENTS;
+ }
+
+ String userAgentLower = userAgent.toLowerCase(Locale.ROOT);
+
+ for (String agentPrefix : agents.split(",")) {
+ if (StringUtils.isNotBlank(agentPrefix) &&
userAgentLower.startsWith(agentPrefix.trim().toLowerCase(Locale.ROOT))) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static String resolveUserAgent(String userAgent,
HttpServletRequest httpRequest) {
+ if (StringUtils.isNotBlank(userAgent)) {
+ return userAgent;
+ }
+
+ return httpRequest != null ?
httpRequest.getHeader(HTTPUtil.USER_AGENT) : null;
+ }
+
+ private List<HttpSession> findActiveUiSessionsForUser(String loginId,
HttpSession currentSession) {
+ CopyOnWriteArrayList<HttpSession> activeHttpSessions =
RangerHttpSessionListener.getActiveSessionOnServer();
+ List<HttpSession> matching = new
ArrayList<>();
+
+ if (CollectionUtils.isEmpty(activeHttpSessions)) {
+ return matching;
+ }
+
+ for (HttpSession httpSession : activeHttpSessions) {
+ if (httpSession == null || httpSession ==
currentSession) {
+ continue;
+ }
+
+ try {
+ if
(Boolean.TRUE.equals(httpSession.getAttribute(SESSION_ATTR_CONCURRENT_EXPIRED)))
{
+ continue;
+ }
+
+ if
(Boolean.TRUE.equals(httpSession.getAttribute(SESSION_ATTR_DOWNLOAD_ONLY))
+ ||
Boolean.TRUE.equals(httpSession.getAttribute(SESSION_ATTR_NON_UI))) {
+ continue;
+ }
+
+ if
(httpSession.getAttribute(RangerSecurityContextFormationFilter.AKA_SC_SESSION_KEY)
== null) {
+ continue;
+ }
+
+ RangerSecurityContext securityContext =
(RangerSecurityContext)
httpSession.getAttribute(RangerSecurityContextFormationFilter.AKA_SC_SESSION_KEY);
+ UserSessionBase userSession =
securityContext != null ? securityContext.getUserSession() : null;
+
+ if (userSession != null &&
loginId.equalsIgnoreCase(userSession.getLoginId())) {
+ matching.add(httpSession);
+ }
+ } catch (IllegalStateException e) {
+ logger.debug("Skipping invalidated session
while counting concurrent sessions", e);
+ }
+ }
+
+ return matching;
+ }
+
+ private void expireConcurrentSession(HttpSession httpSession) {
+ try {
+ RangerSecurityContext context =
(RangerSecurityContext)
httpSession.getAttribute(RangerSecurityContextFormationFilter.AKA_SC_SESSION_KEY);
+ UserSessionBase userSession = context != null ?
context.getUserSession() : null;
+ boolean ssoOrProxy = userSession != null
+ &&
(Boolean.TRUE.equals(userSession.isSSOEnabled()) ||
Boolean.TRUE.equals(userSession.isSpnegoEnabled()));
+
+
httpSession.setAttribute(SESSION_ATTR_CONCURRENT_EXPIRED, Boolean.TRUE);
+
httpSession.setAttribute(SESSION_ATTR_CONCURRENT_EXPIRED_SSO, ssoOrProxy);
+
+ if (context != null) {
+ context.setUserSession(null);
+ }
+
+ logger.info("Expired concurrent Ranger Admin session
(ssoOrTrustedProxy={})", ssoOrProxy);
+
+ if (!ssoOrProxy) {
+ httpSession.invalidate();
+ }
+ } catch (IllegalStateException e) {
+ logger.debug("Session already invalidated while
enforcing concurrent session limit", e);
+ }
+ }
+
protected boolean validateUserSession(UserSessionBase userSession,
String currentLoginId) {
if (currentLoginId
diff --git
a/security-admin/src/main/java/org/apache/ranger/security/web/filter/RangerKRBAuthenticationFilter.java
b/security-admin/src/main/java/org/apache/ranger/security/web/filter/RangerKRBAuthenticationFilter.java
index 30ac069fe..99baab21f 100644
---
a/security-admin/src/main/java/org/apache/ranger/security/web/filter/RangerKRBAuthenticationFilter.java
+++
b/security-admin/src/main/java/org/apache/ranger/security/web/filter/RangerKRBAuthenticationFilter.java
@@ -61,9 +61,11 @@
import org.apache.hadoop.security.authorize.AuthorizationException;
import org.apache.hadoop.security.authorize.ProxyUsers;
import org.apache.hadoop.util.HttpExceptionUtils;
+import org.apache.ranger.biz.SessionMgr;
import org.apache.ranger.biz.UserMgr;
import org.apache.ranger.common.PropertiesUtil;
import org.apache.ranger.common.RESTErrorUtil;
+import org.apache.ranger.common.RangerConstants;
import org.apache.ranger.util.RestUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -333,8 +335,16 @@ protected void doFilter(FilterChain filterChain,
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain filterChain) throws IOException,
ServletException {
+ HttpServletRequest httpRequest = (HttpServletRequest) request;
+ HttpServletResponse httpResponse = (HttpServletResponse)
response;
+ HttpSession httpSession =
httpRequest.getSession(false);
+
+ if (SessionMgr.isConcurrentSessionExpired(httpSession)) {
+ handleConcurrentSessionExpiredRequest(httpRequest,
httpResponse);
+ return;
+ }
+
String authtype = PropertiesUtil.getProperty(RANGER_AUTH_TYPE);
- HttpServletRequest httpRequest = (HttpServletRequest)request;
Authentication existingAuth =
SecurityContextHolder.getContext().getAuthentication();
if(isSpnegoEnable(authtype) && (existingAuth == null ||
!existingAuth.isAuthenticated())){
KerberosName.setRules(PropertiesUtil.getProperty(NAME_RULES, "DEFAULT"));
@@ -347,7 +357,7 @@ public void doFilter(ServletRequest request,
ServletResponse response,
}
try{
if
(StringUtils.equals(httpRequest.getParameter("action"),
RestUtil.TIMEOUT_ACTION)) {
- handleTimeoutRequest(httpRequest,
(HttpServletResponse) response);
+ handleTimeoutRequest(httpRequest,
httpResponse);
} else {
super.doFilter(request, response,
filterChain);
}
@@ -371,7 +381,6 @@ public void doFilter(ServletRequest request,
ServletResponse response,
if (allowTrustedProxy &&
StringUtils.isNotEmpty(doAsUser) && existingAuth != null &&
existingAuth.isAuthenticated()
&& StringUtils.equals(action,
RestUtil.TIMEOUT_ACTION)) {
- HttpServletResponse httpResponse =
(HttpServletResponse) response;
handleTimeoutRequest(httpRequest, httpResponse);
} else {
filterChain.doFilter(request, response);
@@ -379,6 +388,38 @@ public void doFilter(ServletRequest request,
ServletResponse response,
}
}
+ private void handleConcurrentSessionExpiredRequest(HttpServletRequest
httpRequest, HttpServletResponse httpResponse) throws IOException {
+ HttpSession httpSession = httpRequest.getSession(false);
+
+ if (SessionMgr.isConcurrentSessionExpiredSso(httpSession)) {
+ LOG.info("Concurrent session expired for SSO/Trusted
Proxy user; redirecting to Knox login");
+ handleTimeoutRequest(httpRequest, httpResponse);
+ return;
+ }
+
+ LOG.info("Concurrent session expired; redirecting to Ranger
login");
+
+ if (httpSession != null) {
+ try {
+ httpSession.invalidate();
+ } catch (IllegalStateException e) {
+ LOG.debug("Session already invalidated", e);
+ }
+ }
+
+ String loginPage =
PropertiesUtil.getProperty("ranger.logout.success.page", "/login.jsp");
+ String redirectUrl = httpRequest.getContextPath() + loginPage;
+ String ajaxHeader = httpRequest.getHeader("X-Requested-With");
+
+ if ("XMLHttpRequest".equalsIgnoreCase(ajaxHeader)) {
+ httpResponse.setHeader("X-Frame-Options", "DENY");
+
httpResponse.setStatus(RangerConstants.SC_AUTHENTICATION_TIMEOUT);
+ httpResponse.setHeader("X-Rngr-Redirect-Url",
redirectUrl);
+ } else {
+ httpResponse.sendRedirect(redirectUrl);
+ }
+ }
+
private void handleTimeoutRequest(HttpServletRequest httpRequest,
HttpServletResponse httpResponse) throws IOException{
String xForwardedURL =
RestUtil.constructForwardableURL(httpRequest);
if (LOG.isDebugEnabled()) {
diff --git
a/security-admin/src/main/resources/conf.dist/ranger-admin-default-site.xml
b/security-admin/src/main/resources/conf.dist/ranger-admin-default-site.xml
index 164b63d02..ce9ac191d 100644
--- a/security-admin/src/main/resources/conf.dist/ranger-admin-default-site.xml
+++ b/security-admin/src/main/resources/conf.dist/ranger-admin-default-site.xml
@@ -178,6 +178,19 @@
<name>ranger.admin.login.autolock.maxfailure</name>
<value>5</value>
</property>
+ <property>
+ <name>ranger.session.limit.concurrency</name>
+ <value>0</value>
+ <description>
+ Maximum number of concurrent Ranger Admin UI sessions
per user.
+ When the limit is exceeded, the oldest UI session is
expired so the new login succeeds.
+ 0 or a negative value means no limit (default). Plugin
policy/tag/role download
+ sessions and non-browser REST/API clients (for example
scripted RangerClient or curl)
+ do not count toward this limit. Browser detection uses
ranger.krb.browser-useragents-regex.
+ Enforcement is per Ranger Admin process using that
node's in-memory session list.
+ In an HA or load-balanced deployment a user can hold up
to this many UI sessions on each node.
+ </description>
+ </property>
<!-- # anonymous access -->
<property>
diff --git a/security-admin/src/main/webapp/WEB-INF/web.xml
b/security-admin/src/main/webapp/WEB-INF/web.xml
index c2f1c985e..c340a26e5 100644
--- a/security-admin/src/main/webapp/WEB-INF/web.xml
+++ b/security-admin/src/main/webapp/WEB-INF/web.xml
@@ -33,6 +33,9 @@
<listener>
<listener-class>org.springframework.web.context.request.RequestContextListener</listener-class>
</listener>
+ <listener>
+
<listener-class>org.apache.ranger.security.listener.RangerHttpSessionListener</listener-class>
+ </listener>
<filter>
<filter-name>springSecurityFilterChain</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
diff --git
a/security-admin/src/test/java/org/apache/ranger/biz/TestSessionMgr.java
b/security-admin/src/test/java/org/apache/ranger/biz/TestSessionMgr.java
index 6b3baaa58..1cba3c1e2 100644
--- a/security-admin/src/test/java/org/apache/ranger/biz/TestSessionMgr.java
+++ b/security-admin/src/test/java/org/apache/ranger/biz/TestSessionMgr.java
@@ -73,8 +73,13 @@
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
@@ -87,7 +92,9 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -126,6 +133,7 @@ public class TestSessionMgr {
public void tearDownSuperUserConfig() {
PropertiesUtil.getPropertiesMap().remove(RangerConstants.RANGER_ADMIN_SUPER_USERS);
PropertiesUtil.getPropertiesMap().remove(RangerConstants.RANGER_ADMIN_SUPER_GROUPS);
+
PropertiesUtil.getPropertiesMap().remove(SessionMgr.PROP_SESSION_LIMIT_CONCURRENCY);
RangerSuperUserConfig.resetForTests();
}
@@ -693,4 +701,404 @@ public void
testSetUserRoles_ConfigSuperUserGrantsKeyAdminForSysAdmin() {
PropertiesUtil.getPropertiesMap().remove(RangerConstants.RANGER_ADMIN_SUPER_USERS);
}
+
+ @Test
+ public void testProcessSuccessLogin_LimitZeroDoesNotExpireOldestSession() {
+ RangerContextHolder.setSecurityContext(null);
+
PropertiesUtil.getPropertiesMap().put(SessionMgr.PROP_SESSION_LIMIT_CONCURRENCY,
"0");
+
+ setupAuthentication("limitUser");
+
+ XXPortalUser portalUser = portalUser("limitUser", 70L);
+ stubPortalUserLookup(portalUser);
+ stubRolesAndPermissions(portalUser);
+ stubAuthSessionCreate(200L);
+
when(httpUtil.getDeviceType(anyString())).thenReturn(RangerCommonEnums.DEVICE_UNKNOWN);
+
+ HttpSession currentSession = mock(HttpSession.class);
+ when(currentSession.getAttribute("auditLoginId")).thenReturn(null);
+
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getSession()).thenReturn(currentSession);
+ when(request.getRequestURI()).thenReturn("/index.html");
+ when(request.getAttribute("spnegoEnabled")).thenReturn(null);
+
+ UserSessionBase ret =
sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_PASSWORD, "Mozilla/5.0",
request);
+
+ assertNotNull(ret);
+ assertEquals(70L, ret.getUserId());
+ }
+
+ @Test
+ public void
testProcessSuccessLogin_LimitOneExpiresOldestFormLoginSession() {
+ RangerContextHolder.setSecurityContext(null);
+
PropertiesUtil.getPropertiesMap().put(SessionMgr.PROP_SESSION_LIMIT_CONCURRENCY,
"1");
+
+ setupAuthentication("limitUser");
+
+ XXPortalUser portalUser = portalUser("limitUser", 71L);
+ stubPortalUserLookup(portalUser);
+ stubRolesAndPermissions(portalUser);
+ stubAuthSessionCreate(201L);
+
when(httpUtil.getDeviceType(anyString())).thenReturn(RangerCommonEnums.DEVICE_UNKNOWN);
+
+ HttpSession currentSession = mock(HttpSession.class);
+ when(currentSession.getAttribute("auditLoginId")).thenReturn(null);
+
+ HttpSession oldestSession = mockUiSession("limitUser", 71L, false, 1L);
+
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getSession()).thenReturn(currentSession);
+ when(request.getRequestURI()).thenReturn("/index.html");
+ when(request.getAttribute("spnegoEnabled")).thenReturn(null);
+
+ try (MockedStatic<RangerHttpSessionListener> mocked =
Mockito.mockStatic(RangerHttpSessionListener.class)) {
+ CopyOnWriteArrayList<HttpSession> sessions = new
CopyOnWriteArrayList<>();
+ sessions.add(oldestSession);
+
mocked.when(RangerHttpSessionListener::getActiveSessionOnServer).thenReturn(sessions);
+
+ UserSessionBase ret =
sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_PASSWORD, "Mozilla/5.0",
request);
+
+ assertNotNull(ret);
+
verify(oldestSession).setAttribute(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED,
Boolean.TRUE);
+
verify(oldestSession).setAttribute(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED_SSO,
false);
+ verify(oldestSession).invalidate();
+ }
+ }
+
+ @Test
+ public void
testProcessSuccessLogin_LimitOneMarksOldestSsoSessionExpiredWithoutInvalidate()
{
+ RangerContextHolder.setSecurityContext(null);
+
PropertiesUtil.getPropertiesMap().put(SessionMgr.PROP_SESSION_LIMIT_CONCURRENCY,
"1");
+
+ setupAuthentication("ssoUser");
+
+ XXPortalUser portalUser = portalUser("ssoUser", 72L);
+ stubPortalUserLookup(portalUser);
+ stubRolesAndPermissions(portalUser);
+ stubAuthSessionCreate(202L);
+
when(httpUtil.getDeviceType(anyString())).thenReturn(RangerCommonEnums.DEVICE_UNKNOWN);
+
+ HttpSession currentSession = mock(HttpSession.class);
+ when(currentSession.getAttribute("auditLoginId")).thenReturn(null);
+
+ HttpSession oldestSession = mockUiSession("ssoUser", 72L, true, 1L);
+
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getSession()).thenReturn(currentSession);
+ when(request.getRequestURI()).thenReturn("/index.html");
+ when(request.getAttribute("spnegoEnabled")).thenReturn(Boolean.TRUE);
+
+ try (MockedStatic<RangerHttpSessionListener> mocked =
Mockito.mockStatic(RangerHttpSessionListener.class)) {
+ CopyOnWriteArrayList<HttpSession> sessions = new
CopyOnWriteArrayList<>();
+ sessions.add(oldestSession);
+
mocked.when(RangerHttpSessionListener::getActiveSessionOnServer).thenReturn(sessions);
+
+ UserSessionBase ret =
sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_TRUSTED_PROXY,
"Mozilla/5.0", request);
+
+ assertNotNull(ret);
+ assertTrue(ret.isSSOEnabled());
+
verify(oldestSession).setAttribute(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED,
Boolean.TRUE);
+
verify(oldestSession).setAttribute(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED_SSO,
true);
+ verify(oldestSession, never()).invalidate();
+ }
+ }
+
+ @Test
+ public void
testProcessSuccessLogin_DownloadRequestDoesNotConsumeSessionQuota() {
+ RangerContextHolder.setSecurityContext(null);
+
PropertiesUtil.getPropertiesMap().put(SessionMgr.PROP_SESSION_LIMIT_CONCURRENCY,
"1");
+
+ setupAuthentication("limitUser");
+
+ XXPortalUser portalUser = portalUser("limitUser", 73L);
+ stubPortalUserLookup(portalUser);
+ stubRolesAndPermissions(portalUser);
+
when(httpUtil.getDeviceType(anyString())).thenReturn(RangerCommonEnums.DEVICE_UNKNOWN);
+
+ HttpSession currentSession = mock(HttpSession.class);
+ when(currentSession.getAttribute("auditLoginId")).thenReturn(null);
+
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getSession()).thenReturn(currentSession);
+
when(request.getRequestURI()).thenReturn("/service/plugins/policies/download/hadoopdev");
+ when(request.getAttribute("spnegoEnabled")).thenReturn(null);
+
+ UserSessionBase ret =
sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_PASSWORD, "Mozilla/5.0",
request);
+
+ assertNotNull(ret);
+
verify(currentSession).setAttribute(SessionMgr.SESSION_ATTR_DOWNLOAD_ONLY,
Boolean.TRUE);
+ }
+
+ @Test
+ public void testProcessSuccessLogin_ApiRequestDoesNotConsumeSessionQuota()
{
+ RangerContextHolder.setSecurityContext(null);
+
PropertiesUtil.getPropertiesMap().put(SessionMgr.PROP_SESSION_LIMIT_CONCURRENCY,
"1");
+
+ setupAuthentication("limitUser");
+
+ XXPortalUser portalUser = portalUser("limitUser", 74L);
+ stubPortalUserLookup(portalUser);
+ stubRolesAndPermissions(portalUser);
+ stubAuthSessionCreate(204L);
+
when(httpUtil.getDeviceType(anyString())).thenReturn(RangerCommonEnums.DEVICE_UNKNOWN);
+
+ HttpSession currentSession = mock(HttpSession.class);
+ when(currentSession.getAttribute("auditLoginId")).thenReturn(null);
+
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getSession()).thenReturn(currentSession);
+
when(request.getRequestURI()).thenReturn("/service/public/v2/api/policies");
+ when(request.getAttribute("spnegoEnabled")).thenReturn(null);
+
+ UserSessionBase ret =
sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_PASSWORD, "curl/8.0",
request);
+
+ assertNotNull(ret);
+ verify(currentSession).setAttribute(SessionMgr.SESSION_ATTR_NON_UI,
Boolean.TRUE);
+ verify(currentSession,
never()).setAttribute(eq(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED), any());
+ }
+
+ @Test
+ public void
testProcessSuccessLogin_LimitTwoExpiresOnlyOldestOfTwoExistingSessions() {
+ RangerContextHolder.setSecurityContext(null);
+
PropertiesUtil.getPropertiesMap().put(SessionMgr.PROP_SESSION_LIMIT_CONCURRENCY,
"2");
+
+ setupAuthentication("limitUser");
+
+ XXPortalUser portalUser = portalUser("limitUser", 75L);
+ stubPortalUserLookup(portalUser);
+ stubRolesAndPermissions(portalUser);
+ stubAuthSessionCreate(205L);
+
when(httpUtil.getDeviceType(anyString())).thenReturn(RangerCommonEnums.DEVICE_UNKNOWN);
+
+ HttpSession currentSession = mock(HttpSession.class);
+ when(currentSession.getAttribute("auditLoginId")).thenReturn(null);
+
+ HttpSession oldestSession = mockUiSession("limitUser", 75L, false, 1L);
+ HttpSession newerSession = mockUiSession("limitUser", 75L, false, 2L);
+
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getSession()).thenReturn(currentSession);
+ when(request.getRequestURI()).thenReturn("/index.html");
+ when(request.getAttribute("spnegoEnabled")).thenReturn(null);
+
+ try (MockedStatic<RangerHttpSessionListener> mocked =
Mockito.mockStatic(RangerHttpSessionListener.class)) {
+ CopyOnWriteArrayList<HttpSession> sessions = new
CopyOnWriteArrayList<>();
+ sessions.add(newerSession);
+ sessions.add(oldestSession);
+
mocked.when(RangerHttpSessionListener::getActiveSessionOnServer).thenReturn(sessions);
+
+ UserSessionBase ret =
sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_PASSWORD, "Mozilla/5.0",
request);
+
+ assertNotNull(ret);
+
verify(oldestSession).setAttribute(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED,
Boolean.TRUE);
+ verify(oldestSession).invalidate();
+ verify(newerSession,
never()).setAttribute(eq(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED), any());
+ verify(newerSession, never()).invalidate();
+ }
+ }
+
+ @Test
+ public void testProcessSuccessLogin_DoesNotExpireOtherUsersSessions() {
+ RangerContextHolder.setSecurityContext(null);
+
PropertiesUtil.getPropertiesMap().put(SessionMgr.PROP_SESSION_LIMIT_CONCURRENCY,
"1");
+
+ setupAuthentication("userB");
+
+ XXPortalUser portalUser = portalUser("userB", 76L);
+ stubPortalUserLookup(portalUser);
+ stubRolesAndPermissions(portalUser);
+ stubAuthSessionCreate(206L);
+
when(httpUtil.getDeviceType(anyString())).thenReturn(RangerCommonEnums.DEVICE_UNKNOWN);
+
+ HttpSession currentSession = mock(HttpSession.class);
+ when(currentSession.getAttribute("auditLoginId")).thenReturn(null);
+
+ HttpSession otherUserSession = mockUiSession("userA", 77L, false, 1L);
+
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getSession()).thenReturn(currentSession);
+ when(request.getRequestURI()).thenReturn("/index.html");
+ when(request.getAttribute("spnegoEnabled")).thenReturn(null);
+
+ try (MockedStatic<RangerHttpSessionListener> mocked =
Mockito.mockStatic(RangerHttpSessionListener.class)) {
+ CopyOnWriteArrayList<HttpSession> sessions = new
CopyOnWriteArrayList<>();
+ sessions.add(otherUserSession);
+
mocked.when(RangerHttpSessionListener::getActiveSessionOnServer).thenReturn(sessions);
+
+ UserSessionBase ret =
sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_PASSWORD, "Mozilla/5.0",
request);
+
+ assertNotNull(ret);
+ assertEquals(76L, ret.getUserId());
+ verify(otherUserSession,
never()).setAttribute(eq(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED), any());
+ verify(otherUserSession, never()).invalidate();
+ }
+ }
+
+ @Test
+ public void
testEnforceConcurrentSessionLimit_ConcurrentSameUserExpiresOldest() throws
Exception {
+
PropertiesUtil.getPropertiesMap().put(SessionMgr.PROP_SESSION_LIMIT_CONCURRENCY,
"1");
+
+ CountDownLatch concurrentExpiredReads = new CountDownLatch(2);
+ HttpSession oldestSession = mockUiSession("limitUser",
80L, false, 1L, concurrentExpiredReads);
+ HttpSession sessionA = mock(HttpSession.class);
+ HttpSession sessionB = mock(HttpSession.class);
+
+ CopyOnWriteArrayList<HttpSession> active =
RangerHttpSessionListener.getActiveSessionOnServer();
+ active.add(oldestSession);
+
+ CountDownLatch start = new CountDownLatch(1);
+ CountDownLatch done = new CountDownLatch(2);
+ AtomicReference<Throwable> error = new AtomicReference<>();
+
+ try {
+ Thread threadA = new Thread(() -> {
+ try {
+ start.await();
+ sessionMgr.enforceConcurrentSessionLimit("limitUser",
sessionA);
+ } catch (Throwable t) {
+ error.compareAndSet(null, t);
+ } finally {
+ done.countDown();
+ }
+ });
+ Thread threadB = new Thread(() -> {
+ try {
+ start.await();
+ sessionMgr.enforceConcurrentSessionLimit("LimitUser",
sessionB);
+ } catch (Throwable t) {
+ error.compareAndSet(null, t);
+ } finally {
+ done.countDown();
+ }
+ });
+
+ threadA.start();
+ threadB.start();
+ start.countDown();
+
+ assertTrue(done.await(5, TimeUnit.SECONDS));
+ assertNull(error.get());
+ verify(oldestSession,
times(1)).setAttribute(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED,
Boolean.TRUE);
+ verify(oldestSession, times(1)).invalidate();
+ } finally {
+ active.remove(oldestSession);
+ }
+ }
+
+ @Test
+ public void testIsBrowserUserAgent() {
+ assertTrue(SessionMgr.isBrowserUserAgent("Mozilla/5.0"));
+ assertTrue(SessionMgr.isBrowserUserAgent("Chrome/120.0"));
+ assertFalse(SessionMgr.isBrowserUserAgent("curl/8.0"));
+ assertFalse(SessionMgr.isBrowserUserAgent("Apache-HttpClient/4.5"));
+ assertFalse(SessionMgr.isBrowserUserAgent(null));
+ assertFalse(SessionMgr.isBrowserUserAgent(""));
+ }
+
+ @Test
+ public void testIsPluginOrSecureDownloadRequest() {
+
assertTrue(SessionMgr.isPluginOrSecureDownloadRequest("/service/plugins/policies/download/hadoopdev"));
+
assertTrue(SessionMgr.isPluginOrSecureDownloadRequest("/service/secure/policies/download/1"));
+ assertFalse(SessionMgr.isPluginOrSecureDownloadRequest("/index.html"));
+ assertFalse(SessionMgr.isPluginOrSecureDownloadRequest(null));
+ assertFalse(SessionMgr.isPluginOrSecureDownloadRequest(""));
+ }
+
+ private void setupAuthentication(String loginId) {
+ Authentication authentication = mock(Authentication.class);
+ WebAuthenticationDetails details =
mock(WebAuthenticationDetails.class);
+ when(authentication.getDetails()).thenReturn(details);
+ when(authentication.getName()).thenReturn(loginId);
+ when(details.getSessionId()).thenReturn("httpSess1");
+ when(details.getRemoteAddress()).thenReturn("127.0.0.1");
+ SecurityContext sc = SecurityContextHolder.createEmptyContext();
+ sc.setAuthentication(authentication);
+ SecurityContextHolder.setContext(sc);
+ }
+
+ private XXPortalUser portalUser(String loginId, Long id) {
+ XXPortalUser portalUser = new XXPortalUser();
+ portalUser.setId(id);
+ portalUser.setLoginId(loginId);
+ return portalUser;
+ }
+
+ private void stubPortalUserLookup(XXPortalUser portalUser) {
+ XXPortalUserDao portalDao = mock(XXPortalUserDao.class);
+ when(daoManager.getXXPortalUser()).thenReturn(portalDao);
+
when(portalDao.findByLoginId(portalUser.getLoginId())).thenReturn(portalUser);
+ }
+
+ private void stubRolesAndPermissions(XXPortalUser portalUser) {
+ XXPortalUserRoleDao roleDao = mock(XXPortalUserRoleDao.class);
+ when(daoManager.getXXPortalUserRole()).thenReturn(roleDao);
+
when(roleDao.findByUserId(portalUser.getId())).thenReturn(Collections.emptyList());
+ XXUserDao xxUserDao = mock(XXUserDao.class);
+ when(daoManager.getXXUser()).thenReturn(xxUserDao);
+ XXUser xUser = new XXUser();
+ xUser.setId(portalUser.getId());
+ xUser.setName(portalUser.getLoginId());
+
when(xxUserDao.findByUserName(portalUser.getLoginId())).thenReturn(xUser);
+ XXModuleDefDao moduleDefDao = mock(XXModuleDefDao.class);
+ when(daoManager.getXXModuleDef()).thenReturn(moduleDefDao);
+ when(moduleDefDao.findAccessibleModulesByUserId(portalUser.getId(),
portalUser.getId())).thenReturn(Collections.emptyList());
+ }
+
+ private void stubAuthSessionCreate(Long id) {
+ XXAuthSession created = new XXAuthSession();
+ created.setId(id);
+ XXAuthSessionDao authDao = mock(XXAuthSessionDao.class);
+ when(daoManager.getXXAuthSession()).thenReturn(authDao);
+ when(authDao.create(any(XXAuthSession.class))).thenReturn(created);
+ }
+
+ private HttpSession mockUiSession(String loginId, Long userId, boolean
ssoEnabled, long creationTime) {
+ return mockUiSession(loginId, userId, ssoEnabled, creationTime, null);
+ }
+
+ private HttpSession mockUiSession(String loginId, Long userId, boolean
ssoEnabled, long creationTime, CountDownLatch concurrentExpiredReads) {
+ HttpSession httpSession = mock(HttpSession.class);
+ UserSessionBase userSession = new UserSessionBase();
+ XXPortalUser portalUser = portalUser(loginId, userId);
+ userSession.setXXPortalUser(portalUser);
+ userSession.setSSOEnabled(ssoEnabled);
+ RangerSecurityContext securityContext = new RangerSecurityContext();
+ securityContext.setUserSession(userSession);
+ Map<String, Object> attributes = new ConcurrentHashMap<>();
+
attributes.put(RangerSecurityContextFormationFilter.AKA_SC_SESSION_KEY,
securityContext);
+ when(httpSession.getAttribute(anyString())).thenAnswer(invocation -> {
+ String name = invocation.getArgument(0);
+
+ Object value = attributes.get(name);
+
+ if (concurrentExpiredReads != null
+ && SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED.equals(name)
+ && value == null) {
+ concurrentExpiredReads.countDown();
+
+ try {
+ concurrentExpiredReads.await(250, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ return value;
+ });
+ lenient().doAnswer(invocation -> {
+ String name = invocation.getArgument(0);
+ Object value = invocation.getArgument(1);
+
+ if (value == null) {
+ attributes.remove(name);
+ } else {
+ attributes.put(name, value);
+ }
+
+ return null;
+ }).when(httpSession).setAttribute(anyString(), any());
+ lenient().when(httpSession.getCreationTime()).thenReturn(creationTime);
+ return httpSession;
+ }
}
diff --git
a/security-admin/src/test/java/org/apache/ranger/security/web/filter/TestRangerKRBAuthenticationFilter.java
b/security-admin/src/test/java/org/apache/ranger/security/web/filter/TestRangerKRBAuthenticationFilter.java
new file mode 100644
index 000000000..0022d0d24
--- /dev/null
+++
b/security-admin/src/test/java/org/apache/ranger/security/web/filter/TestRangerKRBAuthenticationFilter.java
@@ -0,0 +1,126 @@
+/*
+ * 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.ranger.security.web.filter;
+
+import org.apache.ranger.biz.SessionMgr;
+import org.apache.ranger.common.RangerConstants;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.mockito.Mockito;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.Collections;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Concurrent-session expiry handling for RangerKRBAuthenticationFilter
(RANGER-5749).
+ * This class is new on ranger-2.10; master already had a broader filter test
suite.
+ */
+@TestMethodOrder(MethodOrderer.MethodName.class)
+public class TestRangerKRBAuthenticationFilter {
+ @Test
+ public void testDoFilter_concurrentSessionExpiredSso_redirectsToKnox()
throws Exception {
+ RangerKRBAuthenticationFilter filter = new
RangerKRBAuthenticationFilter();
+
+ HttpServletRequest req = Mockito.mock(HttpServletRequest.class);
+ HttpServletResponse res = Mockito.mock(HttpServletResponse.class);
+ FilterChain chain = Mockito.mock(FilterChain.class);
+ HttpSession session = Mockito.mock(HttpSession.class);
+
+ when(res.getWriter()).thenReturn(new PrintWriter(new StringWriter()));
+ when(req.getSession(false)).thenReturn(session);
+
when(session.getAttribute(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED)).thenReturn(Boolean.TRUE);
+
when(session.getAttribute(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED_SSO)).thenReturn(Boolean.TRUE);
+ when(req.getRequestedSessionId()).thenReturn("sid");
+ when(req.getRequestURI()).thenReturn("/index.html");
+ when(req.getRequestURL()).thenReturn(new
StringBuffer("http://localhost/index.html"));
+ when(req.getHeaderNames()).thenReturn(Collections.emptyEnumeration());
+ doNothing().when(res).sendRedirect(Mockito.anyString());
+ doNothing().when(session).invalidate();
+
+ filter.doFilter(req, res, chain);
+
+ verify(res, times(1)).sendRedirect(Mockito.anyString());
+ verify(session, times(1)).invalidate();
+ verify(chain, never()).doFilter(any(ServletRequest.class),
any(ServletResponse.class));
+ }
+
+ @Test
+ public void
testDoFilter_concurrentSessionExpiredFormLogin_redirectsToRangerLogin() throws
Exception {
+ RangerKRBAuthenticationFilter filter = new
RangerKRBAuthenticationFilter();
+
+ HttpServletRequest req = Mockito.mock(HttpServletRequest.class);
+ HttpServletResponse res = Mockito.mock(HttpServletResponse.class);
+ FilterChain chain = Mockito.mock(FilterChain.class);
+ HttpSession session = Mockito.mock(HttpSession.class);
+
+ when(req.getSession(false)).thenReturn(session);
+
when(session.getAttribute(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED)).thenReturn(Boolean.TRUE);
+
when(session.getAttribute(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED_SSO)).thenReturn(Boolean.FALSE);
+ when(req.getContextPath()).thenReturn("");
+ doNothing().when(res).sendRedirect(Mockito.anyString());
+ doNothing().when(session).invalidate();
+
+ filter.doFilter(req, res, chain);
+
+ verify(session, times(1)).invalidate();
+ verify(res, times(1)).sendRedirect("/login.jsp");
+ verify(chain, never()).doFilter(any(ServletRequest.class),
any(ServletResponse.class));
+ }
+
+ @Test
+ public void
testDoFilter_concurrentSessionExpiredFormLogin_ajaxReturnsTimeout() throws
Exception {
+ RangerKRBAuthenticationFilter filter = new
RangerKRBAuthenticationFilter();
+
+ HttpServletRequest req = Mockito.mock(HttpServletRequest.class);
+ HttpServletResponse res = Mockito.mock(HttpServletResponse.class);
+ FilterChain chain = Mockito.mock(FilterChain.class);
+ HttpSession session = Mockito.mock(HttpSession.class);
+
+ when(req.getSession(false)).thenReturn(session);
+
when(session.getAttribute(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED)).thenReturn(Boolean.TRUE);
+
when(session.getAttribute(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED_SSO)).thenReturn(Boolean.FALSE);
+ when(req.getContextPath()).thenReturn("");
+ when(req.getHeader("X-Requested-With")).thenReturn("XMLHttpRequest");
+ doNothing().when(session).invalidate();
+
+ filter.doFilter(req, res, chain);
+
+ verify(session, times(1)).invalidate();
+ verify(res).setStatus(RangerConstants.SC_AUTHENTICATION_TIMEOUT);
+ verify(res).setHeader("X-Rngr-Redirect-Url", "/login.jsp");
+ verify(res, never()).sendRedirect(Mockito.anyString());
+ verify(chain, never()).doFilter(any(ServletRequest.class),
any(ServletResponse.class));
+ }
+}