From adb14bda5a09947eb4c537be1d8dfd081c83cd45 Mon Sep 17 00:00:00 2001 From: eoinmcdonnell113 Date: Mon, 31 Aug 2026 10:26:42 -0400 Subject: [PATCH 1/3] RANGER-5749: Limit concurrent Ranger Admin UI sessions per user Expire the oldest UI session when ranger.session.limit.concurrency is exceeded so a new login succeeds. Default 0 means unlimited. --- .../org/apache/ranger/biz/SessionMgr.java | 154 +++++++++++++ .../listener/RangerHttpSessionListener.java | 2 + .../filter/RangerKRBAuthenticationFilter.java | 43 +++- .../conf.dist/ranger-admin-default-site.xml | 10 + .../src/main/webapp/WEB-INF/web.xml | 3 + .../org/apache/ranger/biz/TestSessionMgr.java | 207 ++++++++++++++++++ .../TestRangerKRBAuthenticationFilter.java | 51 +++++ 7 files changed, 464 insertions(+), 6 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 ef10779f833..5f449b890cb 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 @@ -62,6 +62,7 @@ import java.util.ArrayList; import java.util.Calendar; +import java.util.Comparator; import java.util.Date; import java.util.HashSet; import java.util.List; @@ -74,6 +75,11 @@ 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"; + private static final Long SESSION_UPDATE_INTERVAL_IN_MILLIS = 30 * DateUtils.MILLIS_PER_MINUTE; @Autowired @@ -214,6 +220,18 @@ public UserSessionBase processSuccessLogin(int authType, String userAgent, HttpS logger.debug("Login Success: loginId={}, sessionId={}, details is null, epoch={}", currentLoginId, gjAuthSession.getId(), cal.getTimeInMillis()); } } + + 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 { + enforceConcurrentSessionLimit(currentLoginId, session); + } + } } return userSession; @@ -498,6 +516,142 @@ 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. + */ + 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; + } + + List 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)); + } + } + + 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/"); + } + + private List findActiveUiSessionsForUser(String loginId, HttpSession currentSession) { + CopyOnWriteArrayList activeHttpSessions = RangerHttpSessionListener.getActiveSessionOnServer(); + List 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))) { + 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.equalsIgnoreCase(userSession.getXXPortalUser().getLoginId())) { return true; diff --git a/security-admin/src/main/java/org/apache/ranger/security/listener/RangerHttpSessionListener.java b/security-admin/src/main/java/org/apache/ranger/security/listener/RangerHttpSessionListener.java index a13b16a99e3..e415c20f5cb 100644 --- a/security-admin/src/main/java/org/apache/ranger/security/listener/RangerHttpSessionListener.java +++ b/security-admin/src/main/java/org/apache/ranger/security/listener/RangerHttpSessionListener.java @@ -19,12 +19,14 @@ package org.apache.ranger.security.listener; +import javax.servlet.annotation.WebListener; import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSessionEvent; import javax.servlet.http.HttpSessionListener; import java.util.concurrent.CopyOnWriteArrayList; +@WebListener public class RangerHttpSessionListener implements HttpSessionListener { private static final CopyOnWriteArrayList listOfSession = new CopyOnWriteArrayList<>(); 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 f8bb49312d7..29f4413b124 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 @@ -30,6 +30,7 @@ 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; @@ -465,9 +466,17 @@ public Enumeration getInitParameterNames() { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException { - String authtype = PropertiesUtil.getProperty(RANGER_AUTH_TYPE); - HttpServletRequest httpRequest = (HttpServletRequest) request; - Authentication existingAuth = SecurityContextHolder.getContext().getAuthentication(); + 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); + Authentication existingAuth = SecurityContextHolder.getContext().getAuthentication(); if (isSpnegoEnable(authtype) && (existingAuth == null || !existingAuth.isAuthenticated())) { KerberosName.setRules(PropertiesUtil.getProperty(NAME_RULES, "DEFAULT")); @@ -482,7 +491,7 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha try { if (StringUtils.equals(httpRequest.getParameter("action"), RestUtil.TIMEOUT_ACTION)) { - handleTimeoutRequest(httpRequest, (HttpServletResponse) response); + handleTimeoutRequest(httpRequest, httpResponse); } else { super.doFilter(request, response, filterChain); } @@ -503,8 +512,6 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha } 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); @@ -682,6 +689,30 @@ protected Configuration getProxyuserConfiguration() { return conf; } + 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"); + + httpResponse.sendRedirect(httpRequest.getContextPath() + loginPage); + } + private void handleTimeoutRequest(HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws IOException { String xForwardedURL = RestUtil.constructForwardableURL(httpRequest); 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 164b63d027a..a1b3097508e 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,16 @@ ranger.admin.login.autolock.maxfailure 5 + + ranger.session.limit.concurrency + 0 + + Maximum number of concurrent Ranger Admin UI sessions per user. + When the limit is exceeded, the oldest session is expired so the new login succeeds. + 0 or a negative value means no limit (default). Plugin policy/tag/role download + sessions do not count toward this limit. + + diff --git a/security-admin/src/main/webapp/WEB-INF/web.xml b/security-admin/src/main/webapp/WEB-INF/web.xml index b10693f891b..526102ecc3b 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 @@ org.springframework.web.context.request.RequestContextListener + + org.apache.ranger.security.listener.RangerHttpSessionListener + springSecurityFilterChain org.springframework.web.filter.DelegatingFilterProxy 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 68033907b3f..d2a7055d897 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 @@ -88,6 +88,7 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; 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; @@ -129,6 +130,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(); } @@ -730,4 +732,209 @@ 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, "UA", 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 mocked = Mockito.mockStatic(RangerHttpSessionListener.class)) { + CopyOnWriteArrayList sessions = new CopyOnWriteArrayList<>(); + sessions.add(oldestSession); + mocked.when(RangerHttpSessionListener::getActiveSessionOnServer).thenReturn(sessions); + + UserSessionBase ret = sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_PASSWORD, "UA", 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 mocked = Mockito.mockStatic(RangerHttpSessionListener.class)) { + CopyOnWriteArrayList sessions = new CopyOnWriteArrayList<>(); + sessions.add(oldestSession); + mocked.when(RangerHttpSessionListener::getActiveSessionOnServer).thenReturn(sessions); + + UserSessionBase ret = sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_TRUSTED_PROXY, "UA", 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); + stubAuthSessionCreate(203L); + 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, "UA", request); + + assertNotNull(ret); + verify(currentSession).setAttribute(SessionMgr.SESSION_ATTR_DOWNLOAD_ONLY, Boolean.TRUE); + } + + @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) { + 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); + when(httpSession.getAttribute(anyString())).thenAnswer(invocation -> { + String name = invocation.getArgument(0); + if (RangerSecurityContextFormationFilter.AKA_SC_SESSION_KEY.equals(name)) { + return securityContext; + } + return null; + }); + 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 index f9b52929395..c3e28c60a43 100644 --- 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 @@ -24,6 +24,7 @@ import org.apache.hadoop.security.authentication.server.AuthenticationToken; import org.apache.hadoop.security.authentication.util.RandomSignerSecretProvider; import org.apache.hadoop.security.authentication.util.Signer; +import org.apache.ranger.biz.SessionMgr; import org.apache.ranger.biz.UserMgr; import org.apache.ranger.common.PropertiesUtil; import org.junit.jupiter.api.AfterEach; @@ -126,6 +127,56 @@ public void testDoFilter_handlesTimeoutWithTrustedProxy() throws Exception { verify(chain, never()).doFilter(any(ServletRequest.class), any(ServletResponse.class)); } + @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_nonSpnego_delegatesToChain() throws Exception { PropertiesUtil.getPropertiesMap().put("hadoop.security.authentication", "simple"); From 6569451b0f88d9b04c28b041523bbca483494f26 Mon Sep 17 00:00:00 2001 From: eoinmcdonnell113 Date: Tue, 1 Sep 2026 09:37:13 -0400 Subject: [PATCH 2/3] RANGER-5749: Address review comments on concurrent UI session limit Keep a single session listener, count only browser UI sessions, return 419 for AJAX kicks, and serialize find-and-expire per user. --- .../org/apache/ranger/biz/SessionMgr.java | 84 +++++++-- .../listener/RangerHttpSessionListener.java | 2 - .../filter/RangerKRBAuthenticationFilter.java | 13 +- .../conf.dist/ranger-admin-default-site.xml | 7 +- .../org/apache/ranger/biz/TestSessionMgr.java | 178 +++++++++++++++++- .../TestRangerKRBAuthenticationFilter.java | 26 +++ 6 files changed, 283 insertions(+), 27 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 5f449b890cb..d8ed398485b 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 @@ -66,7 +66,9 @@ 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; @@ -79,6 +81,9 @@ public class SessionMgr { 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 CONCURRENT_SESSION_LOCKS = new ConcurrentHashMap<>(); private static final Long SESSION_UPDATE_INTERVAL_IN_MILLIS = 30 * DateUtils.MILLIS_PER_MINUTE; @@ -169,7 +174,7 @@ public UserSessionBase processSuccessLogin(int authType, String userAgent, HttpS 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()); @@ -228,6 +233,12 @@ public UserSessionBase processSuccessLogin(int authType, String userAgent, HttpS } 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); } @@ -543,6 +554,9 @@ public static boolean isConcurrentSessionExpiredSso(HttpSession session) { /** * 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); @@ -551,29 +565,38 @@ protected void enforceConcurrentSessionLimit(String loginId, HttpSession current return; } - List otherSessions = findActiveUiSessionsForUser(loginId, currentSession); + Object lock = CONCURRENT_SESSION_LOCKS.computeIfAbsent(loginId.toLowerCase(Locale.ROOT), id -> new Object()); - if (otherSessions.size() < limit) { - return; - } + synchronized (lock) { + List otherSessions = findActiveUiSessionsForUser(loginId, currentSession); - otherSessions.sort(Comparator.comparingLong(session -> { - try { - return session.getCreationTime(); - } catch (IllegalStateException e) { - return 0L; + if (otherSessions.size() < limit) { + return; } - })); - int toExpire = otherSessions.size() - limit + 1; + 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); + 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)); + 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; @@ -588,6 +611,34 @@ static boolean isPluginOrSecureDownloadRequest(String uri) { || 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; + } + + for (String agentPrefix : agents.split(",")) { + if (StringUtils.isNotBlank(agentPrefix) && userAgent.toLowerCase().startsWith(agentPrefix.trim().toLowerCase())) { + 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 findActiveUiSessionsForUser(String loginId, HttpSession currentSession) { CopyOnWriteArrayList activeHttpSessions = RangerHttpSessionListener.getActiveSessionOnServer(); List matching = new ArrayList<>(); @@ -606,7 +657,8 @@ private List findActiveUiSessionsForUser(String loginId, HttpSessio continue; } - if (Boolean.TRUE.equals(httpSession.getAttribute(SESSION_ATTR_DOWNLOAD_ONLY))) { + if (Boolean.TRUE.equals(httpSession.getAttribute(SESSION_ATTR_DOWNLOAD_ONLY)) + || Boolean.TRUE.equals(httpSession.getAttribute(SESSION_ATTR_NON_UI))) { continue; } diff --git a/security-admin/src/main/java/org/apache/ranger/security/listener/RangerHttpSessionListener.java b/security-admin/src/main/java/org/apache/ranger/security/listener/RangerHttpSessionListener.java index e415c20f5cb..a13b16a99e3 100644 --- a/security-admin/src/main/java/org/apache/ranger/security/listener/RangerHttpSessionListener.java +++ b/security-admin/src/main/java/org/apache/ranger/security/listener/RangerHttpSessionListener.java @@ -19,14 +19,12 @@ package org.apache.ranger.security.listener; -import javax.servlet.annotation.WebListener; import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSessionEvent; import javax.servlet.http.HttpSessionListener; import java.util.concurrent.CopyOnWriteArrayList; -@WebListener public class RangerHttpSessionListener implements HttpSessionListener { private static final CopyOnWriteArrayList listOfSession = new CopyOnWriteArrayList<>(); 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 29f4413b124..7ee172768b4 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 @@ -34,6 +34,7 @@ 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; @@ -708,9 +709,17 @@ private void handleConcurrentSessionExpiredRequest(HttpServletRequest httpReques } } - String loginPage = PropertiesUtil.getProperty("ranger.logout.success.page", "/login.jsp"); + String loginPage = PropertiesUtil.getProperty("ranger.logout.success.page", "/login.jsp"); + String redirectUrl = httpRequest.getContextPath() + loginPage; + String ajaxHeader = httpRequest.getHeader("X-Requested-With"); - httpResponse.sendRedirect(httpRequest.getContextPath() + loginPage); + 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 { 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 a1b3097508e..ce9ac191d89 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 @@ -183,9 +183,12 @@ 0 Maximum number of concurrent Ranger Admin UI sessions per user. - When the limit is exceeded, the oldest session is expired so the new login succeeds. + 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 do not count toward this limit. + 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. 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 d2a7055d897..dc946453d06 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 @@ -75,6 +75,9 @@ import java.util.List; import java.util.Set; 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,6 +90,8 @@ 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.atLeastOnce; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; @@ -754,7 +759,7 @@ public void testProcessSuccessLogin_LimitZeroDoesNotExpireOldestSession() { when(request.getRequestURI()).thenReturn("/index.html"); when(request.getAttribute("spnegoEnabled")).thenReturn(null); - UserSessionBase ret = sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_PASSWORD, "UA", request); + UserSessionBase ret = sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_PASSWORD, "Mozilla/5.0", request); assertNotNull(ret); assertEquals(70L, ret.getUserId()); @@ -788,7 +793,7 @@ public void testProcessSuccessLogin_LimitOneExpiresOldestFormLoginSession() { sessions.add(oldestSession); mocked.when(RangerHttpSessionListener::getActiveSessionOnServer).thenReturn(sessions); - UserSessionBase ret = sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_PASSWORD, "UA", request); + UserSessionBase ret = sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_PASSWORD, "Mozilla/5.0", request); assertNotNull(ret); verify(oldestSession).setAttribute(SessionMgr.SESSION_ATTR_CONCURRENT_EXPIRED, Boolean.TRUE); @@ -825,7 +830,7 @@ public void testProcessSuccessLogin_LimitOneMarksOldestSsoSessionExpiredWithoutI sessions.add(oldestSession); mocked.when(RangerHttpSessionListener::getActiveSessionOnServer).thenReturn(sessions); - UserSessionBase ret = sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_TRUSTED_PROXY, "UA", request); + UserSessionBase ret = sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_TRUSTED_PROXY, "Mozilla/5.0", request); assertNotNull(ret); assertTrue(ret.isSSOEnabled()); @@ -845,7 +850,6 @@ public void testProcessSuccessLogin_DownloadRequestDoesNotConsumeSessionQuota() XXPortalUser portalUser = portalUser("limitUser", 73L); stubPortalUserLookup(portalUser); stubRolesAndPermissions(portalUser); - stubAuthSessionCreate(203L); when(httpUtil.getDeviceType(anyString())).thenReturn(RangerCommonEnums.DEVICE_UNKNOWN); HttpSession currentSession = mock(HttpSession.class); @@ -856,12 +860,176 @@ public void testProcessSuccessLogin_DownloadRequestDoesNotConsumeSessionQuota() when(request.getRequestURI()).thenReturn("/service/plugins/policies/download/hadoopdev"); when(request.getAttribute("spnegoEnabled")).thenReturn(null); - UserSessionBase ret = sessionMgr.processSuccessLogin(XXAuthSession.AUTH_TYPE_PASSWORD, "UA", request); + 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 mocked = Mockito.mockStatic(RangerHttpSessionListener.class)) { + CopyOnWriteArrayList 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 mocked = Mockito.mockStatic(RangerHttpSessionListener.class)) { + CopyOnWriteArrayList 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"); + + HttpSession oldestSession = mockUiSession("limitUser", 80L, false, 1L); + HttpSession sessionA = mock(HttpSession.class); + HttpSession sessionB = mock(HttpSession.class); + + CopyOnWriteArrayList active = RangerHttpSessionListener.getActiveSessionOnServer(); + active.add(oldestSession); + + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(2); + AtomicReference 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, atLeastOnce()).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")); 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 index c3e28c60a43..2547b4ddcaa 100644 --- 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 @@ -27,6 +27,7 @@ import org.apache.ranger.biz.SessionMgr; import org.apache.ranger.biz.UserMgr; import org.apache.ranger.common.PropertiesUtil; +import org.apache.ranger.common.RangerConstants; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.MethodOrderer; @@ -177,6 +178,31 @@ public void testDoFilter_concurrentSessionExpiredFormLogin_redirectsToRangerLogi 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)); + } + @Test public void testDoFilter_nonSpnego_delegatesToChain() throws Exception { PropertiesUtil.getPropertiesMap().put("hadoop.security.authentication", "simple"); From ea5d12fe4e6613182812c65cc65fc2f7330d5a2c Mon Sep 17 00:00:00 2001 From: eoinmcdonnell113 Date: Tue, 1 Sep 2026 16:46:31 -0400 Subject: [PATCH 3/3] RANGER-5749: Lowercase user-agent once in browser session check Avoid converting the user-agent on every prefix comparison. --- .../src/main/java/org/apache/ranger/biz/SessionMgr.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 d8ed398485b..1d5416235e6 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 @@ -622,8 +622,10 @@ static boolean isBrowserUserAgent(String userAgent) { agents = DEFAULT_BROWSER_USER_AGENTS; } + String userAgentLower = userAgent.toLowerCase(Locale.ROOT); + for (String agentPrefix : agents.split(",")) { - if (StringUtils.isNotBlank(agentPrefix) && userAgent.toLowerCase().startsWith(agentPrefix.trim().toLowerCase())) { + if (StringUtils.isNotBlank(agentPrefix) && userAgentLower.startsWith(agentPrefix.trim().toLowerCase(Locale.ROOT))) { return true; } }