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 f59ebdea0b..55e37fcbcb 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 @@ -63,10 +63,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; @@ -77,6 +80,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 CONCURRENT_SESSION_LOCKS = new ConcurrentHashMap<>(); + private static final Long SESSION_UPDATE_INTERVAL_IN_MILLIS = 30 * DateUtils.MILLIS_PER_MINUTE; @Autowired @@ -175,7 +186,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()); @@ -226,6 +237,24 @@ 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 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; @@ -510,6 +539,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 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 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)) + || 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.equalsIgnoreCase(userSession.getXXPortalUser().getLoginId())) { return true; 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 f8bb49312d..7ee172768b 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,9 +30,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; @@ -465,9 +467,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 +492,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 +513,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 +690,38 @@ 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"); + 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); 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 164b63d027..ce9ac191d8 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 @@ 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 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. + + diff --git a/security-admin/src/main/webapp/WEB-INF/web.xml b/security-admin/src/main/webapp/WEB-INF/web.xml index b10693f891..526102ecc3 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 68033907b3..dc946453d0 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,7 +90,10 @@ 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; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -129,6 +135,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 +737,372 @@ 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 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, "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 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, "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 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")); + 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 f9b5292939..2547b4ddca 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,8 +24,10 @@ 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.apache.ranger.common.RangerConstants; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.MethodOrderer; @@ -126,6 +128,81 @@ 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_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");