ACS-1217 Additional option for SOLR to authenticate with a shared secret (#334)

Co-authored-by: Alex Mukha <alex.mukha@alfresco.com>
This commit is contained in:
Stefan Kopf
2021-03-04 18:49:49 +01:00
committed by GitHub
parent 2e6b40d8c7
commit fef8cc9256
6 changed files with 260 additions and 192 deletions

View File

@@ -66,6 +66,8 @@
<bean id="SOLRAuthenticationFilter" class="org.alfresco.repo.web.scripts.solr.SOLRAuthenticationFilter"> <bean id="SOLRAuthenticationFilter" class="org.alfresco.repo.web.scripts.solr.SOLRAuthenticationFilter">
<property name="secureComms" value="${solr.secureComms}"/> <property name="secureComms" value="${solr.secureComms}"/>
<property name="sharedSecret" value="${solr.sharedSecret}"/>
<property name="sharedSecretHeader" value="${solr.sharedSecret.header}"/>
</bean> </bean>
<bean id="WebscriptAuthenticationFilter" class="org.alfresco.repo.management.subsystems.ChainingSubsystemProxyFactory"> <bean id="WebscriptAuthenticationFilter" class="org.alfresco.repo.management.subsystems.ChainingSubsystemProxyFactory">

View File

@@ -1,28 +1,28 @@
/* /*
* #%L * #%L
* Alfresco Remote API * Alfresco Remote API
* %% * %%
* Copyright (C) 2005 - 2016 Alfresco Software Limited * Copyright (C) 2005 - 2016 Alfresco Software Limited
* %% * %%
* This file is part of the Alfresco software. * This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of * If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is * the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms: * provided under the following open source license terms:
* *
* Alfresco is free software: you can redistribute it and/or modify * Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by * it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (at your option) any later version.
* *
* Alfresco is distributed in the hope that it will be useful, * Alfresco is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details. * GNU Lesser General Public License for more details.
* *
* You should have received a copy of the GNU Lesser General Public License * You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>. * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L% * #L%
*/ */
package org.alfresco.repo.web.scripts.solr; package org.alfresco.repo.web.scripts.solr;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
@@ -43,20 +43,22 @@ import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.repo.web.filter.beans.DependencyInjectedFilter; import org.alfresco.repo.web.filter.beans.DependencyInjectedFilter;
import org.apache.commons.logging.Log; import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory; import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.InitializingBean;
/** /**
* This filter protects the solr callback urls by verifying MACs on requests and encrypting responses * This filter protects the solr callback urls by verifying a shared secret on the request header if
* and generating MACs on responses, if the secureComms property is set to "md5". If it is set to "https" * the secureComms property is set to "secret". If it is set to "https", this will will just verify
* or "none", the filter does nothing to the request and response. * that the request came in through a "secure" tomcat connector. (but it will not validate the certificate
* * on the request; this done in a different filter).
*
* @since 4.0 * @since 4.0
* *
*/ */
public class SOLRAuthenticationFilter implements DependencyInjectedFilter public class SOLRAuthenticationFilter implements DependencyInjectedFilter, InitializingBean
{ {
public static enum SecureCommsType public static enum SecureCommsType
{ {
HTTPS, NONE; HTTPS, SECRET, NONE;
public static SecureCommsType getType(String type) public static SecureCommsType getType(String type)
{ {
@@ -64,6 +66,10 @@ public class SOLRAuthenticationFilter implements DependencyInjectedFilter
{ {
return HTTPS; return HTTPS;
} }
else if(type.equalsIgnoreCase("secret"))
{
return SECRET;
}
else if(type.equalsIgnoreCase("none")) else if(type.equalsIgnoreCase("none"))
{ {
return NONE; return NONE;
@@ -79,7 +85,13 @@ public class SOLRAuthenticationFilter implements DependencyInjectedFilter
private static Log logger = LogFactory.getLog(SOLRAuthenticationFilter.class); private static Log logger = LogFactory.getLog(SOLRAuthenticationFilter.class);
private SecureCommsType secureComms = SecureCommsType.HTTPS; private SecureCommsType secureComms = SecureCommsType.HTTPS;
private String sharedSecret;
private String sharedSecretHeader = DEFAULT_SHAREDSECRET_HEADER;
private static final String DEFAULT_SHAREDSECRET_HEADER = "X-Alfresco-Search-Secret";
public void setSecureComms(String type) public void setSecureComms(String type)
{ {
try try
@@ -92,6 +104,33 @@ public class SOLRAuthenticationFilter implements DependencyInjectedFilter
} }
} }
public void setSharedSecret(String sharedSecret)
{
this.sharedSecret = sharedSecret;
}
public void setSharedSecretHeader(String sharedSecretHeader)
{
this.sharedSecretHeader = sharedSecretHeader;
}
@Override
public void afterPropertiesSet() throws Exception
{
if(secureComms == SecureCommsType.SECRET)
{
if(sharedSecret == null || sharedSecret.length()==0)
{
logger.fatal("Missing value for solr.sharedSecret configuration property. If solr.secureComms is set to \"secret\", a value for solr.sharedSecret is required. See https://docs.alfresco.com/search-services/latest/install/options/");
throw new AlfrescoRuntimeException("Missing value for solr.sharedSecret configuration property");
}
if(sharedSecretHeader == null || sharedSecretHeader.length()==0)
{
throw new AlfrescoRuntimeException("Missing value for sharedSecretHeader");
}
}
}
public void doFilter(ServletContext context, ServletRequest request, public void doFilter(ServletContext context, ServletRequest request,
ServletResponse response, FilterChain chain) throws IOException, ServletResponse response, FilterChain chain) throws IOException,
ServletException ServletException
@@ -99,52 +138,22 @@ public class SOLRAuthenticationFilter implements DependencyInjectedFilter
HttpServletRequest httpRequest = (HttpServletRequest)request; HttpServletRequest httpRequest = (HttpServletRequest)request;
HttpServletResponse httpResponse = (HttpServletResponse)response; HttpServletResponse httpResponse = (HttpServletResponse)response;
/* if(secureComms == SecureCommsType.ALFRESCO) if(secureComms == SecureCommsType.SECRET)
{ {
// Need to get as a byte array because we need to read the request twice, once for authentication if(sharedSecret.equals(httpRequest.getHeader(sharedSecretHeader)))
// and again by the web service.
SOLRHttpServletRequestWrapper requestWrapper = new SOLRHttpServletRequestWrapper(httpRequest, encryptionUtils);
if(logger.isDebugEnabled())
{ {
logger.debug("Authenticating " + httpRequest.getRequestURI()); chain.doFilter(request, response);
}
if(encryptionUtils.authenticate(httpRequest, requestWrapper.getDecryptedBody()))
{
try
{
OutputStream out = response.getOutputStream();
GenericResponseWrapper responseWrapper = new GenericResponseWrapper(httpResponse);
// TODO - do I need to chain to other authenticating filters - probably not?
// Could also remove sending of credentials with http request
chain.doFilter(requestWrapper, responseWrapper);
Pair<byte[], AlgorithmParameters> pair = encryptor.encrypt(KeyProvider.ALIAS_SOLR, null, responseWrapper.getData());
encryptionUtils.setResponseAuthentication(httpRequest, httpResponse, responseWrapper.getData(), pair.getSecond());
httpResponse.setHeader("Content-Length", Long.toString(pair.getFirst().length));
out.write(pair.getFirst());
out.close();
}
catch(Exception e)
{
throw new AlfrescoRuntimeException("", e);
}
} }
else else
{ {
httpResponse.setStatus(401); httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "Authentication failure");
} }
} }
else */if(secureComms == SecureCommsType.HTTPS) else if(secureComms == SecureCommsType.HTTPS)
{ {
if(httpRequest.isSecure()) if(httpRequest.isSecure())
{ {
// https authentication // https authentication; cert got verified in X509 filter
chain.doFilter(request, response); chain.doFilter(request, response);
} }
else else
@@ -158,128 +167,4 @@ public class SOLRAuthenticationFilter implements DependencyInjectedFilter
} }
} }
protected boolean validateTimestamp(String timestampStr)
{
if(timestampStr == null || timestampStr.equals(""))
{
throw new AlfrescoRuntimeException("Missing timestamp on request");
}
long timestamp = -1;
try
{
timestamp = Long.valueOf(timestampStr);
}
catch(NumberFormatException e)
{
throw new AlfrescoRuntimeException("Invalid timestamp on request");
}
if(timestamp == -1)
{
throw new AlfrescoRuntimeException("Invalid timestamp on request");
}
long currentTime = System.currentTimeMillis();
return((currentTime - timestamp) < 30 * 1000); // 5s
}
/* private static class SOLRHttpServletRequestWrapper extends HttpServletRequestWrapper
{
private byte[] body;
SOLRHttpServletRequestWrapper(HttpServletRequest req, EncryptionUtils encryptionUtils) throws IOException
{
super(req);
this.body = encryptionUtils.decryptBody(req);
}
byte[] getDecryptedBody()
{
return body;
}
public ServletInputStream getInputStream()
{
final InputStream in = (body != null ? new ByteArrayInputStream(body) : null);
return new ServletInputStream()
{
public int read() throws IOException
{
if(in == null)
{
return -1;
}
else
{
int i = in.read();
if(i == -1)
{
in.close();
}
return i;
}
}
};
}
}*/
private static class ByteArrayServletOutputStream extends ServletOutputStream
{
private ByteArrayOutputStream out = new ByteArrayOutputStream();
ByteArrayServletOutputStream()
{
}
public byte[] getData()
{
return out.toByteArray();
}
@Override
public void write(int b) throws IOException
{
out.write(b);
}
}
public static class GenericResponseWrapper extends HttpServletResponseWrapper {
private ByteArrayServletOutputStream output;
private int contentLength;
private String contentType;
public GenericResponseWrapper(HttpServletResponse response) {
super(response);
output = new ByteArrayServletOutputStream();
}
public byte[] getData() {
return output.getData();
}
public ServletOutputStream getOutputStream() {
return output;
}
public PrintWriter getWriter() {
return new PrintWriter(getOutputStream(),true);
}
public void setContentLength(int length) {
this.contentLength = length;
super.setContentLength(length);
}
public int getContentLength() {
return contentLength;
}
public void setContentType(String type) {
this.contentType = type;
super.setContentType(type);
}
public String getContentType() {
return contentType;
}
}
} }

View File

@@ -66,6 +66,8 @@
<bean id="SOLRAuthenticationFilter" class="org.alfresco.repo.web.scripts.solr.SOLRAuthenticationFilter"> <bean id="SOLRAuthenticationFilter" class="org.alfresco.repo.web.scripts.solr.SOLRAuthenticationFilter">
<property name="secureComms" value="${solr.secureComms}"/> <property name="secureComms" value="${solr.secureComms}"/>
<property name="sharedSecret" value="${solr.sharedSecret}"/>
<property name="sharedSecretHeader" value="${solr.sharedSecret.header}"/>
</bean> </bean>
<bean id="WebscriptAuthenticationFilter" class="org.alfresco.repo.management.subsystems.ChainingSubsystemProxyFactory"> <bean id="WebscriptAuthenticationFilter" class="org.alfresco.repo.management.subsystems.ChainingSubsystemProxyFactory">

View File

@@ -39,6 +39,7 @@ import org.junit.runners.Suite;
org.alfresco.repo.web.scripts.workflow.WorkflowModelBuilderTest.class, org.alfresco.repo.web.scripts.workflow.WorkflowModelBuilderTest.class,
org.alfresco.repo.web.scripts.solr.StatsGetTest.class, org.alfresco.repo.web.scripts.solr.StatsGetTest.class,
org.alfresco.repo.web.scripts.solr.SOLRSerializerTest.class, org.alfresco.repo.web.scripts.solr.SOLRSerializerTest.class,
org.alfresco.repo.web.scripts.solr.SOLRAuthenticationFilterTest.class,
org.alfresco.repo.web.util.PagingCursorTest.class, org.alfresco.repo.web.util.PagingCursorTest.class,
org.alfresco.repo.web.util.paging.PagingTest.class, org.alfresco.repo.web.util.paging.PagingTest.class,
org.alfresco.repo.webdav.GetMethodTest.class, org.alfresco.repo.webdav.GetMethodTest.class,

View File

@@ -0,0 +1,176 @@
/*
* #%L
* Alfresco Remote API
* %%
* Copyright (C) 2005 - 2021 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Alfresco is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.repo.web.scripts.solr;
import org.alfresco.error.AlfrescoRuntimeException;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import javax.servlet.FilterChain;
import javax.servlet.ServletContext;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import static org.junit.Assert.assertEquals;
public class SOLRAuthenticationFilterTest
{
@Test(expected = AlfrescoRuntimeException.class)
public void testSharedSecretNotConfigured() throws Exception
{
SOLRAuthenticationFilter filter = new SOLRAuthenticationFilter();
filter.setSecureComms(SOLRAuthenticationFilter.SecureCommsType.SECRET.name());
filter.afterPropertiesSet();
}
@Test(expected = AlfrescoRuntimeException.class)
public void testSharedHeaderNotConfigured() throws Exception
{
SOLRAuthenticationFilter filter = new SOLRAuthenticationFilter();
filter.setSecureComms(SOLRAuthenticationFilter.SecureCommsType.SECRET.name());
filter.setSharedSecret("shared-secret");
filter.setSharedSecretHeader("");
filter.afterPropertiesSet();
}
@Test
public void testHTTPSFilterAndSharedSecretSet() throws Exception
{
String headerKey = "test-header";
String sharedSecret = "shared-secret";
SOLRAuthenticationFilter filter = new SOLRAuthenticationFilter();
filter.setSecureComms(SOLRAuthenticationFilter.SecureCommsType.HTTPS.name());
filter.setSharedSecret(sharedSecret);
filter.setSharedSecretHeader(headerKey);
filter.afterPropertiesSet();
HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
Mockito.when(request.getHeader(headerKey)).thenReturn(sharedSecret);
Mockito.when(request.isSecure()).thenReturn(true);
FilterChain chain = Mockito.mock(FilterChain.class);
filter.doFilter(Mockito.mock(ServletContext.class), request, response, chain);
Mockito.verify(chain, Mockito.times(1)).doFilter(request, response);
}
@Test(expected = AlfrescoRuntimeException.class)
public void testHTTPSFilterAndInsecureRequest() throws Exception
{
SOLRAuthenticationFilter filter = new SOLRAuthenticationFilter();
filter.setSecureComms(SOLRAuthenticationFilter.SecureCommsType.HTTPS.name());
filter.afterPropertiesSet();
HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
Mockito.when(request.isSecure()).thenReturn(false);
FilterChain chain = Mockito.mock(FilterChain.class);
filter.doFilter(Mockito.mock(ServletContext.class), request, response, chain);
}
@Test
public void testNoAuthentication() throws Exception
{
SOLRAuthenticationFilter filter = new SOLRAuthenticationFilter();
filter.setSecureComms(SOLRAuthenticationFilter.SecureCommsType.NONE.name());
filter.afterPropertiesSet();
HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
FilterChain chain = Mockito.mock(FilterChain.class);
filter.doFilter(Mockito.mock(ServletContext.class), request, response, chain);
Mockito.verify(chain, Mockito.times(1)).doFilter(request, response);
}
@Test
public void testSharedSecretFilter() throws Exception
{
String headerKey = "test-header";
String sharedSecret = "shared-secret";
SOLRAuthenticationFilter filter = new SOLRAuthenticationFilter();
filter.setSecureComms(SOLRAuthenticationFilter.SecureCommsType.SECRET.name());
filter.setSharedSecret(sharedSecret);
filter.setSharedSecretHeader(headerKey);
filter.afterPropertiesSet();
HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
Mockito.when(request.getHeader(headerKey)).thenReturn(sharedSecret);
FilterChain chain = Mockito.mock(FilterChain.class);
filter.doFilter(Mockito.mock(ServletContext.class), request, response, chain);
Mockito.verify(chain, Mockito.times(1)).doFilter(request, response);
}
@Test
public void testSharedSecretDontMatch() throws Exception
{
String headerKey = "test-header";
String sharedSecret = "shared-secret";
SOLRAuthenticationFilter filter = new SOLRAuthenticationFilter();
filter.setSecureComms(SOLRAuthenticationFilter.SecureCommsType.SECRET.name());
filter.setSharedSecret(sharedSecret);
filter.setSharedSecretHeader(headerKey);
filter.afterPropertiesSet();
HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
Mockito.when(request.getHeader(headerKey)).thenReturn("wrong-secret");
FilterChain chain = Mockito.mock(FilterChain.class);
filter.doFilter(Mockito.mock(ServletContext.class), request, response, chain);
Mockito.verify(chain, Mockito.times(0)).doFilter(request, response);
Mockito.verify(response).sendError(Mockito.eq(HttpServletResponse.SC_FORBIDDEN), Mockito.anyString());
}
@Test
public void testSharedHeaderNotPresent() throws Exception
{
String headerKey = "test-header";
String sharedSecret = "shared-secret";
SOLRAuthenticationFilter filter = new SOLRAuthenticationFilter();
filter.setSecureComms(SOLRAuthenticationFilter.SecureCommsType.SECRET.name());
filter.setSharedSecret(sharedSecret);
filter.setSharedSecretHeader(headerKey);
filter.afterPropertiesSet();
HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
HttpServletResponse response = Mockito.mock(HttpServletResponse.class);
FilterChain chain = Mockito.mock(FilterChain.class);
filter.doFilter(Mockito.mock(ServletContext.class), request, response, chain);
Mockito.verify(chain, Mockito.times(0)).doFilter(request, response);
Mockito.verify(response).sendError(Mockito.eq(HttpServletResponse.SC_FORBIDDEN), Mockito.anyString());
}
}

View File

@@ -747,6 +747,8 @@ solr.solrUser=solr
solr.solrPassword=solr solr.solrPassword=solr
# none, https # none, https
solr.secureComms=https solr.secureComms=https
solr.sharedSecret=
solr.sharedSecret.header=X-Alfresco-Search-Secret
solr.cmis.alternativeDictionary=DEFAULT_DICTIONARY solr.cmis.alternativeDictionary=DEFAULT_DICTIONARY
solr.max.total.connections=40 solr.max.total.connections=40