diff --git a/src/server/src/main/docker/shiro.ini b/src/server/src/main/docker/shiro.ini index 36a3a9042..5acd64283 100644 --- a/src/server/src/main/docker/shiro.ini +++ b/src/server/src/main/docker/shiro.ini @@ -16,6 +16,17 @@ # by default it is not used. see cassandra-reaper.yaml [main] +sessionManager = org.apache.shiro.web.session.mgt.DefaultWebSessionManager +sessionManager.sessionIdCookieEnabled = true +sessionManager.sessionIdCookie.secure = true +sessionManager.sessionIdCookie.sameSite = NONE +securityManager.sessionManager = $sessionManager + +rememberMeManager = org.apache.shiro.web.mgt.CookieRememberMeManager +rememberMeManager.cookie.secure = true +rememberMeManager.cookie.sameSite = NONE +securityManager.rememberMeManager = $rememberMeManager + authc = org.apache.shiro.web.filter.authc.PassThruAuthenticationFilter authc.loginUrl = /webui/login.html diff --git a/src/server/src/main/java/io/cassandrareaper/ReaperApplication.java b/src/server/src/main/java/io/cassandrareaper/ReaperApplication.java index abae72569..d95ed371a 100644 --- a/src/server/src/main/java/io/cassandrareaper/ReaperApplication.java +++ b/src/server/src/main/java/io/cassandrareaper/ReaperApplication.java @@ -38,6 +38,7 @@ import io.cassandrareaper.resources.ReaperResource; import io.cassandrareaper.resources.RepairRunResource; import io.cassandrareaper.resources.RepairScheduleResource; +import io.cassandrareaper.resources.RequestUtils; import io.cassandrareaper.resources.SnapshotResource; import io.cassandrareaper.resources.auth.LoginResource; import io.cassandrareaper.resources.auth.ShiroExceptionMapper; @@ -185,11 +186,12 @@ public void run(ReaperApplicationConfiguration config, Environment environment) TimeUnit.SECONDS, maxParallelRepairs); + RequestUtils.setCorsEnabled(config.isEnableCrossOrigin()); // Enable cross-origin requests for using external GUI applications. if (config.isEnableCrossOrigin() || System.getProperty("enableCrossOrigin") != null) { FilterRegistration.Dynamic co = environment.servlets().addFilter("crossOriginRequests", CrossOriginFilter.class); co.setInitParameter("allowedOrigins", "*"); - co.setInitParameter("allowedHeaders", "X-Requested-With,Content-Type,Accept,Origin"); + co.setInitParameter("allowedHeaders", "X-Requested-With,Content-Type,Accept,Origin,Authorization"); co.setInitParameter("allowedMethods", "OPTIONS,GET,PUT,POST,DELETE,HEAD,PATCH"); co.addMappingForUrlPatterns(EnumSet.allOf(DispatcherType.class), true, "/*"); } @@ -205,7 +207,6 @@ public void run(ReaperApplicationConfiguration config, Environment environment) environment.jersey().register(pingResource); final ClusterResource addClusterResource = ClusterResource.create(context, cryptograph); - environment.jersey().register(addClusterResource); final RepairRunResource addRepairRunResource = new RepairRunResource(context); environment.jersey().register(addRepairRunResource); diff --git a/src/server/src/main/java/io/cassandrareaper/resources/RequestUtils.java b/src/server/src/main/java/io/cassandrareaper/resources/RequestUtils.java new file mode 100644 index 000000000..6e163e682 --- /dev/null +++ b/src/server/src/main/java/io/cassandrareaper/resources/RequestUtils.java @@ -0,0 +1,45 @@ +/* + * + * Copyright 2022-2022 The Last Pickle Ltd + * + * Licensed 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 io.cassandrareaper.resources; + +import javax.servlet.ServletRequest; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.HttpMethod; + +public final class RequestUtils { + private static boolean isCorsEnabled = false; + + private RequestUtils() {} + + public static void setCorsEnabled(boolean enabled) { + isCorsEnabled = enabled; + } + + public static boolean isCorsEnabled() { + return isCorsEnabled; + } + + public static boolean isOptionsRequest(ServletRequest request) { + if (request != null && request instanceof HttpServletRequest) { + if (((HttpServletRequest) request).getMethod().equalsIgnoreCase(HttpMethod.OPTIONS)) { + return true; + } + } + return false; + } +} diff --git a/src/server/src/main/java/io/cassandrareaper/resources/auth/RestPermissionsFilter.java b/src/server/src/main/java/io/cassandrareaper/resources/auth/RestPermissionsFilter.java index 976cef806..48323b70a 100644 --- a/src/server/src/main/java/io/cassandrareaper/resources/auth/RestPermissionsFilter.java +++ b/src/server/src/main/java/io/cassandrareaper/resources/auth/RestPermissionsFilter.java @@ -17,18 +17,34 @@ package io.cassandrareaper.resources.auth; +import io.cassandrareaper.resources.RequestUtils; + import java.io.IOException; + import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletResponse; +import com.google.common.annotations.VisibleForTesting; import org.apache.shiro.subject.Subject; import org.apache.shiro.web.filter.authz.HttpMethodPermissionFilter; import org.apache.shiro.web.util.WebUtils; public final class RestPermissionsFilter extends HttpMethodPermissionFilter { - public RestPermissionsFilter() {} + @VisibleForTesting + boolean isCorsEnabled() { + return RequestUtils.isCorsEnabled(); + } + + @Override + public boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) + throws IOException { + if (isCorsEnabled() && RequestUtils.isOptionsRequest(request)) { + return true; + } + return super.isAccessAllowed(request, response, mappedValue); + } @Override protected Subject getSubject(ServletRequest request, ServletResponse response) { diff --git a/src/server/src/main/java/io/cassandrareaper/resources/auth/ShiroJwtVerifyingFilter.java b/src/server/src/main/java/io/cassandrareaper/resources/auth/ShiroJwtVerifyingFilter.java index c63f32cb4..d6319aada 100644 --- a/src/server/src/main/java/io/cassandrareaper/resources/auth/ShiroJwtVerifyingFilter.java +++ b/src/server/src/main/java/io/cassandrareaper/resources/auth/ShiroJwtVerifyingFilter.java @@ -17,21 +17,27 @@ package io.cassandrareaper.resources.auth; +import io.cassandrareaper.resources.RequestUtils; + import java.util.Optional; + import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletResponse; +import com.google.common.annotations.VisibleForTesting; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.lang.Strings; + import org.apache.shiro.subject.SimplePrincipalCollection; import org.apache.shiro.subject.Subject; import org.apache.shiro.web.filter.AccessControlFilter; import org.apache.shiro.web.subject.WebSubject; import org.apache.shiro.web.util.WebUtils; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,10 +45,17 @@ public final class ShiroJwtVerifyingFilter extends AccessControlFilter { private static final Logger LOG = LoggerFactory.getLogger(ShiroJwtVerifyingFilter.class); - public ShiroJwtVerifyingFilter() {} + @VisibleForTesting + boolean isCorsEnabled() { + return RequestUtils.isCorsEnabled(); + } @Override protected boolean isAccessAllowed(ServletRequest req, ServletResponse res, Object mappedValue) throws Exception { + if (isCorsEnabled() && RequestUtils.isOptionsRequest(req)) { + return true; + } + Subject nonJwt = getSubject(req, res); return null != nonJwt.getPrincipal() && (nonJwt.isRemembered() || nonJwt.isAuthenticated()) diff --git a/src/server/src/main/resources/shiro.ini b/src/server/src/main/resources/shiro.ini index 7f60b3c00..a2532e28f 100644 --- a/src/server/src/main/resources/shiro.ini +++ b/src/server/src/main/resources/shiro.ini @@ -13,6 +13,17 @@ # limitations under the License. [main] +sessionManager = org.apache.shiro.web.session.mgt.DefaultWebSessionManager +sessionManager.sessionIdCookieEnabled = true +sessionManager.sessionIdCookie.secure = true +sessionManager.sessionIdCookie.sameSite = NONE +securityManager.sessionManager = $sessionManager + +rememberMeManager = org.apache.shiro.web.mgt.CookieRememberMeManager +rememberMeManager.cookie.secure = true +rememberMeManager.cookie.sameSite = NONE +securityManager.rememberMeManager = $rememberMeManager + authc = org.apache.shiro.web.filter.authc.PassThruAuthenticationFilter authc.loginUrl = /webui/login.html diff --git a/src/server/src/test/java/io/cassandrareaper/resources/RequestUtilsTest.java b/src/server/src/test/java/io/cassandrareaper/resources/RequestUtilsTest.java new file mode 100644 index 000000000..67b50a675 --- /dev/null +++ b/src/server/src/test/java/io/cassandrareaper/resources/RequestUtilsTest.java @@ -0,0 +1,51 @@ +/* + * + * Copyright 2019-2019 The Last Pickle Ltd + * + * Licensed 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 io.cassandrareaper.resources; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.HttpMethod; + +import org.assertj.core.api.Assertions; +import org.junit.Test; + +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +public class RequestUtilsTest { + @Test + public void testIsOptionsRequestInvalidInputReturnsFalse() { + boolean isOptionsRequest = RequestUtils.isOptionsRequest(null); + Assertions.assertThat(isOptionsRequest).isFalse(); + } + + @Test + public void testIsOptionsRequestOptionsServletInputReturnsTrue() { + HttpServletRequest mockServletRequest = spy(HttpServletRequest.class); + when(mockServletRequest.getMethod()).thenReturn(HttpMethod.OPTIONS); + boolean isOptionsRequest = RequestUtils.isOptionsRequest(mockServletRequest); + Assertions.assertThat(isOptionsRequest).isTrue(); + } + + @Test + public void testIsOptionsRequestGetServletInputReturnsTrue() { + HttpServletRequest mockServletRequest = spy(HttpServletRequest.class); + when(mockServletRequest.getMethod()).thenReturn(HttpMethod.GET); + boolean isOptionsRequest = RequestUtils.isOptionsRequest(mockServletRequest); + Assertions.assertThat(isOptionsRequest).isFalse(); + } +} diff --git a/src/server/src/test/java/io/cassandrareaper/resources/auth/RestPermissionsFilterTest.java b/src/server/src/test/java/io/cassandrareaper/resources/auth/RestPermissionsFilterTest.java new file mode 100644 index 000000000..6620d75d3 --- /dev/null +++ b/src/server/src/test/java/io/cassandrareaper/resources/auth/RestPermissionsFilterTest.java @@ -0,0 +1,45 @@ +/* + * + * Copyright 2022-2022 The Last Pickle Ltd + * + * Licensed 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 io.cassandrareaper.resources.auth; + +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.HttpMethod; + +import org.assertj.core.api.Assertions; +import org.junit.Test; +import org.mockito.Mockito; + +public class RestPermissionsFilterTest { + + @Test + public void testOptionsRequestWithoutAuthorizationIsAllowed() throws Exception { + RestPermissionsFilter filter = Mockito.spy(RestPermissionsFilter.class); + HttpServletRequest mockHttpServletRequest = Mockito.spy(HttpServletRequest.class); + Mockito.when(mockHttpServletRequest.getMethod()).thenReturn(HttpMethod.OPTIONS); + Mockito.when(filter.isCorsEnabled()).thenReturn(true); + + boolean allowed = filter.isAccessAllowed( + mockHttpServletRequest, + Mockito.mock(ServletResponse.class), + Mockito.mock(Object.class) + ); + Assertions.assertThat(allowed).isTrue(); + } + +} diff --git a/src/server/src/test/java/io/cassandrareaper/resources/auth/ShiroJwtVerifyingFilterTest.java b/src/server/src/test/java/io/cassandrareaper/resources/auth/ShiroJwtVerifyingFilterTest.java index 6add87346..d890cd872 100644 --- a/src/server/src/test/java/io/cassandrareaper/resources/auth/ShiroJwtVerifyingFilterTest.java +++ b/src/server/src/test/java/io/cassandrareaper/resources/auth/ShiroJwtVerifyingFilterTest.java @@ -22,6 +22,7 @@ import java.security.Principal; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.HttpMethod; import org.apache.shiro.SecurityUtils; import org.apache.shiro.mgt.DefaultSecurityManager; @@ -31,7 +32,6 @@ import org.junit.Test; import org.mockito.Mockito; - public final class ShiroJwtVerifyingFilterTest { @Test @@ -199,5 +199,19 @@ public void testAuthorizationValid() throws Exception { } } + @Test + public void testOptionsRequestWithoutAuthorizationIsAllowed() throws Exception { + ShiroJwtVerifyingFilter filter = Mockito.spy(ShiroJwtVerifyingFilter.class); + HttpServletRequest mockHttpServletRequest = Mockito.spy(HttpServletRequest.class); + Mockito.when(mockHttpServletRequest.getMethod()).thenReturn(HttpMethod.OPTIONS); + Mockito.when(filter.isCorsEnabled()).thenReturn(true); + + boolean allowed = filter.isAccessAllowed( + mockHttpServletRequest, + Mockito.mock(ServletResponse.class), + Mockito.mock(Object.class) + ); + Assertions.assertThat(allowed).isTrue(); + } }