Fix #406 - Allow arbitrary authentication realm

This commit is contained in:
jamesagnew 2016-08-01 21:36:50 -04:00
parent dd8b1cd979
commit 545b359697
10 changed files with 269 additions and 53 deletions

View File

@ -53,4 +53,14 @@ public class BasicSecurityInterceptor extends InterceptorAdapter
}
//END SNIPPET: basicAuthInterceptor
public void basicAuthInterceptorRealm() {
//START SNIPPET: basicAuthInterceptorRealm
AuthenticationException ex = new AuthenticationException();
ex.addAuthenticateHeaderForRealm("myRealm");
throw ex;
//END SNIPPET: basicAuthInterceptorRealm
}
}

View File

@ -36,6 +36,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.StringTokenizer;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@ -1522,6 +1523,15 @@ public class RestfulServer extends HttpServlet implements IRestfulServer<Servlet
private void writeExceptionToResponse(HttpServletResponse theResponse, BaseServerResponseException theException) throws IOException {
theResponse.setStatus(theException.getStatusCode());
addHeadersToResponse(theResponse);
if (theException.hasResponseHeaders()) {
for (Entry<String, List<String>> nextEntry : theException.getResponseHeaders().entrySet()) {
for (String nextValue : nextEntry.getValue()) {
if (isNotBlank(nextValue)) {
theResponse.addHeader(nextEntry.getKey(), nextValue);
}
}
}
}
theResponse.setContentType("text/plain");
theResponse.setCharacterEncoding("UTF-8");
theResponse.getWriter().write(theException.getMessage());

View File

@ -44,5 +44,16 @@ public class AuthenticationException extends BaseServerResponseException {
public AuthenticationException(String theMessage, Throwable theCause) {
super(STATUS_CODE, theMessage, theCause);
}
/**
* Adds a <code>WWW-Authenticate</code> header to the response, of the form:<br/>
* <code>WWW-Authenticate: Basic realm="theRealm"</code>
*
* @return Returns a reference to <code>this</code> for easy method chaining
*/
public AuthenticationException addAuthenticateHeaderForRealm(String theRealm) {
addResponseHeader("WWW-Authenticate", "Basic realm=\"" + theRealm + "\"");
return this;
}
}

View File

@ -1,12 +1,14 @@
package ca.uhn.fhir.rest.server.exceptions;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.Validate;
import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
import ca.uhn.fhir.rest.server.IResourceProvider;
@ -70,13 +72,10 @@ public abstract class BaseServerResponseException extends RuntimeException {
private List<String> myAdditionalMessages = null;
private IBaseOperationOutcome myBaseOperationOutcome;
private String myResponseBody;
private Map<String, List<String>> myResponseHeaders;
private String myResponseMimeType;
private int myStatusCode;
public static void main (String[] args) {
BaseServerResponseException.class.getName();
}
/**
* Constructor
*
@ -90,7 +89,7 @@ public abstract class BaseServerResponseException extends RuntimeException {
myStatusCode = theStatusCode;
myBaseOperationOutcome = null;
}
/**
* Constructor
*
@ -107,7 +106,7 @@ public abstract class BaseServerResponseException extends RuntimeException {
myAdditionalMessages = Arrays.asList(Arrays.copyOfRange(theMessages, 1, theMessages.length, String[].class));
}
}
/**
* Constructor
*
@ -188,15 +187,26 @@ public abstract class BaseServerResponseException extends RuntimeException {
myBaseOperationOutcome = theBaseOperationOutcome;
}
public List<String> getAdditionalMessages() {
return myAdditionalMessages;
/**
* Add a header which will be added to any responses
*
* @param theName The header name
* @param theValue The header value
* @return Returns a reference to <code>this</code> for easy method chaining
* @since 2.0
*/
public BaseServerResponseException addResponseHeader(String theName, String theValue) {
Validate.notBlank(theName, "theName must not be null or empty");
Validate.notBlank(theValue, "theValue must not be null or empty");
if (getResponseHeaders().containsKey(theName) == false) {
getResponseHeaders().put(theName, new ArrayList<String>());
}
getResponseHeaders().get(theName).add(theValue);
return this;
}
/**
* Returns the HTTP headers associated with this exception.
*/
public Map<String, String[]> getAssociatedHeaders() {
return Collections.emptyMap();
public List<String> getAdditionalMessages() {
return myAdditionalMessages;
}
/**
@ -216,6 +226,21 @@ public abstract class BaseServerResponseException extends RuntimeException {
return myResponseBody;
}
/**
* Returns a map containing any headers which should be added to the outgoing
* response. This methos creates the map if none exists, so it will never
* return <code>null</code>
*
* @since 2.0 (note that this method existed in previous versions of HAPI but the method
* signature has been changed from <code>Map&lt;String, String[]&gt;</code> to <code>Map&lt;String, List&lt;String&gt;&gt;</code>
*/
public Map<String, List<String>> getResponseHeaders() {
if (myResponseHeaders == null) {
myResponseHeaders = new HashMap<String, List<String>>();
}
return myResponseHeaders;
}
/**
* In a RESTful client, this method will be populated with the HTTP status code that was returned with the HTTP response.
* <p>
@ -233,6 +258,16 @@ public abstract class BaseServerResponseException extends RuntimeException {
return myStatusCode;
}
/**
* Does the exception have any headers which should be added to the outgoing response?
*
* @see #getResponseHeaders()
* @since 2.0
*/
public boolean hasResponseHeaders() {
return myResponseHeaders != null && myResponseHeaders.isEmpty() == false;
}
/**
* Sets the BaseOperationOutcome resource associated with this exception. In server implementations, this is the OperartionOutcome resource to include with the HTTP response. In client
* implementations you should not call this method.

View File

@ -1,8 +1,6 @@
package ca.uhn.fhir.rest.server.exceptions;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
@ -20,7 +18,7 @@ import ca.uhn.fhir.rest.server.Constants;
* 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
* 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,
@ -48,11 +46,11 @@ public class MethodNotAllowedException extends BaseServerResponseException {
* Constructor
*
* @param theMessage
* The message
* The message
* @param theOperationOutcome
* The OperationOutcome resource to return to the client
* The OperationOutcome resource to return to the client
* @param theAllowedMethods
* A list of allowed methods (see {@link #setAllowedMethods(RequestTypeEnum...)} )
* A list of allowed methods (see {@link #setAllowedMethods(RequestTypeEnum...)} )
*/
public MethodNotAllowedException(String theMessage, IBaseOperationOutcome theOperationOutcome, RequestTypeEnum... theAllowedMethods) {
super(STATUS_CODE, theMessage, theOperationOutcome);
@ -63,9 +61,9 @@ public class MethodNotAllowedException extends BaseServerResponseException {
* Constructor
*
* @param theMessage
* The message
* The message
* @param theAllowedMethods
* A list of allowed methods (see {@link #setAllowedMethods(RequestTypeEnum...)} )
* A list of allowed methods (see {@link #setAllowedMethods(RequestTypeEnum...)} )
*/
public MethodNotAllowedException(String theMessage, RequestTypeEnum... theAllowedMethods) {
super(STATUS_CODE, theMessage);
@ -76,9 +74,9 @@ public class MethodNotAllowedException extends BaseServerResponseException {
* Constructor
*
* @param theMessage
* The message
* The message
* @param theOperationOutcome
* The OperationOutcome resource to return to the client
* The OperationOutcome resource to return to the client
*/
public MethodNotAllowedException(String theMessage, IBaseOperationOutcome theOperationOutcome) {
super(STATUS_CODE, theMessage, theOperationOutcome);
@ -88,7 +86,7 @@ public class MethodNotAllowedException extends BaseServerResponseException {
* Constructor
*
* @param theMessage
* The message
* The message
*/
public MethodNotAllowedException(String theMessage) {
super(STATUS_CODE, theMessage);
@ -101,22 +99,6 @@ public class MethodNotAllowedException extends BaseServerResponseException {
return myAllowedMethods;
}
@Override
public Map<String, String[]> getAssociatedHeaders() {
if (myAllowedMethods != null && myAllowedMethods.size() > 0) {
StringBuilder b = new StringBuilder();
for (RequestTypeEnum next : myAllowedMethods) {
if (b.length() > 0) {
b.append(',');
}
b.append(next.name());
}
return Collections.singletonMap(Constants.HEADER_ALLOW, new String[] { b.toString() });
} else {
return super.getAssociatedHeaders();
}
}
/**
* Specifies the list of allowed HTTP methods (GET, POST, etc). This is provided in an <code>Allow</code> header, as required by the HTTP specification (RFC 2616).
*/
@ -129,6 +111,7 @@ public class MethodNotAllowedException extends BaseServerResponseException {
myAllowedMethods.add(next);
}
}
updateAllowHeader();
}
/**
@ -136,6 +119,20 @@ public class MethodNotAllowedException extends BaseServerResponseException {
*/
public void setAllowedMethods(Set<RequestTypeEnum> theAllowedMethods) {
myAllowedMethods = theAllowedMethods;
updateAllowHeader();
}
private void updateAllowHeader() {
getResponseHeaders().remove(Constants.HEADER_ALLOW);
StringBuilder b = new StringBuilder();
for (RequestTypeEnum next : myAllowedMethods) {
if (b.length() > 0) {
b.append(',');
}
b.append(next.name());
}
addResponseHeader(Constants.HEADER_ALLOW, b.toString());
}
}

View File

@ -23,6 +23,7 @@ import static org.apache.commons.lang3.StringUtils.isNotBlank;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
@ -68,9 +69,9 @@ public class ExceptionHandlingInterceptor extends InterceptorAdapter {
int statusCode = theException.getStatusCode();
// Add headers associated with the specific error code
Map<String, String[]> additional = theException.getAssociatedHeaders();
if (additional != null) {
for (Entry<String, String[]> next : additional.entrySet()) {
if (theException.hasResponseHeaders()) {
Map<String, List<String>> additional = theException.getResponseHeaders();
for (Entry<String, List<String>> next : additional.entrySet()) {
if (isNotBlank(next.getKey()) && next.getValue() != null) {
String nextKey = next.getKey();
for (String nextValue : next.getValue()) {
@ -79,7 +80,7 @@ public class ExceptionHandlingInterceptor extends InterceptorAdapter {
}
}
}
String statusMessage = null;
if (theException instanceof UnclassifiedServerFailureException) {
String sm = theException.getMessage();
@ -89,12 +90,7 @@ public class ExceptionHandlingInterceptor extends InterceptorAdapter {
}
return response.streamResponseAsResource(oo, true, Collections.singleton(SummaryEnum.FALSE), statusCode, statusMessage, false, false);
// theResponse.setStatus(statusCode);
// theRequestDetails.getServer().addHeadersToResponse(theResponse);
// theResponse.setContentType("text/plain");
// theResponse.setCharacterEncoding("UTF-8");
// theResponse.getWriter().append(theException.getMessage());
// theResponse.getWriter().close();
}
@Override

View File

@ -0,0 +1,138 @@
package ca.uhn.fhir.rest.server;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.hl7.fhir.dstu3.model.OperationOutcome;
import org.hl7.fhir.dstu3.model.OperationOutcome.IssueType;
import org.hl7.fhir.dstu3.model.Patient;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.annotation.Search;
import ca.uhn.fhir.rest.server.exceptions.AuthenticationException;
import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException;
import ca.uhn.fhir.util.PortUtil;
import ca.uhn.fhir.util.TestUtil;
public class ServerExceptionDstu3Test {
private static CloseableHttpClient ourClient;
private static FhirContext ourCtx = FhirContext.forDstu3();
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(ServerExceptionDstu3Test.class);
private static int ourPort;
private static Server ourServer;
public static BaseServerResponseException ourException;
@Test
public void testAddHeadersNotFound() throws Exception {
OperationOutcome operationOutcome = new OperationOutcome();
operationOutcome.addIssue().setCode(IssueType.BUSINESSRULE);
ourException = new ResourceNotFoundException("SOME MESSAGE");
ourException.addResponseHeader("X-Foo", "BAR BAR");
HttpGet httpGet = new HttpGet("http://localhost:" + ourPort + "/Patient");
CloseableHttpResponse status = ourClient.execute(httpGet);
try {
String responseContent = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8);
ourLog.info(status.getStatusLine().toString());
ourLog.info(responseContent);
assertEquals(404, status.getStatusLine().getStatusCode());
assertEquals("BAR BAR", status.getFirstHeader("X-Foo").getValue());
assertThat(status.getFirstHeader("X-Powered-By").getValue(), containsString("HAPI FHIR"));
} finally {
IOUtils.closeQuietly(status.getEntity().getContent());
}
}
@Test
public void testAuthorize() throws Exception {
OperationOutcome operationOutcome = new OperationOutcome();
operationOutcome.addIssue().setCode(IssueType.BUSINESSRULE);
ourException = new AuthenticationException().addAuthenticateHeaderForRealm("REALM");
HttpGet httpGet = new HttpGet("http://localhost:" + ourPort + "/Patient");
CloseableHttpResponse status = ourClient.execute(httpGet);
try {
String responseContent = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8);
ourLog.info(status.getStatusLine().toString());
ourLog.info(responseContent);
assertEquals(401, status.getStatusLine().getStatusCode());
assertEquals("Basic realm=\"REALM\"", status.getFirstHeader("WWW-Authenticate").getValue());
} finally {
IOUtils.closeQuietly(status.getEntity().getContent());
}
}
@AfterClass
public static void afterClassClearContext() throws Exception {
ourServer.stop();
TestUtil.clearAllStaticFieldsForUnitTest();
}
@BeforeClass
public static void beforeClass() throws Exception {
ourPort = PortUtil.findFreePort();
ourServer = new Server(ourPort);
DummyPatientResourceProvider patientProvider = new DummyPatientResourceProvider();
ServletHandler proxyHandler = new ServletHandler();
RestfulServer servlet = new RestfulServer(ourCtx);
servlet.setPagingProvider(new FifoMemoryPagingProvider(10));
servlet.setResourceProviders(patientProvider);
ServletHolder servletHolder = new ServletHolder(servlet);
proxyHandler.addServletWithMapping(servletHolder, "/*");
ourServer.setHandler(proxyHandler);
ourServer.start();
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(5000, TimeUnit.MILLISECONDS);
HttpClientBuilder builder = HttpClientBuilder.create();
builder.setConnectionManager(connectionManager);
ourClient = builder.build();
}
public static class DummyPatientResourceProvider implements IResourceProvider {
@Override
public Class<? extends IBaseResource> getResourceType() {
return Patient.class;
}
@Search()
public List<Patient> search() {
throw ourException;
}
}
}

View File

@ -4,6 +4,7 @@ import static org.hamcrest.Matchers.stringContainsInOrder;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.TimeUnit;
@ -21,13 +22,11 @@ import org.hl7.fhir.dstu3.model.OperationOutcome.IssueType;
import org.hl7.fhir.dstu3.model.Patient;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.annotation.Search;
import ca.uhn.fhir.rest.api.SortSpec;
import ca.uhn.fhir.rest.server.exceptions.UnclassifiedServerFailureException;
import ca.uhn.fhir.util.PortUtil;
import ca.uhn.fhir.util.TestUtil;
@ -51,7 +50,7 @@ public class UnclassifiedServerExceptionDstu3Test {
HttpGet httpGet = new HttpGet("http://localhost:" + ourPort + "/Patient");
CloseableHttpResponse status = ourClient.execute(httpGet);
try {
String responseContent = IOUtils.toString(status.getEntity().getContent());
String responseContent = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8);
ourLog.info(status.getStatusLine().toString());
ourLog.info(responseContent);
assertEquals(477, status.getStatusLine().getStatusCode());

View File

@ -127,6 +127,11 @@
Client that declares explicitly that it is searching/reading/etc for
a custom type did not automatically parse into that type.
</action>
<action type="add" issue="406">
Allow servers to specify the authentication realm of their choosing when
throwing an AuthenticationException. Thanks to GitHub user @allanbrohansen
for the suggestion!
</action>
</release>
<release version="1.6" date="2016-07-07">
<action type="fix">

View File

@ -64,6 +64,21 @@
<param name="id" value="basicAuthInterceptor" />
<param name="file" value="examples/src/main/java/example/SecurityInterceptors.java" />
</macro>
<subsection name="HTTP Basic Auth">
<p>
Note that if you are implementing HTTP Basic Auth, you may want to
return a <code>WWW-Authenticate</code> header with the response.
The following snippet shows how to add such a header with a custom
realm:
</p>
<macro name="snippet">
<param name="id" value="basicAuthInterceptorRealm" />
<param name="file" value="examples/src/main/java/example/SecurityInterceptors.java" />
</macro>
</subsection>
</section>