mirror of https://github.com/apache/jclouds.git
Issue 86: authentication filter for Microsoft Azure Storage
git-svn-id: http://jclouds.googlecode.com/svn/trunk@1863 3d8758e0-26b5-11de-8745-db77d3ebf521
This commit is contained in:
parent
a0192989d4
commit
e3b3ca5e6f
|
@ -0,0 +1,200 @@
|
||||||
|
package org.jclouds.azure.storage.filters;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.SortedSet;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import javax.ws.rs.core.HttpHeaders;
|
||||||
|
|
||||||
|
import org.jclouds.azure.storage.reference.AzureStorageConstants;
|
||||||
|
import org.jclouds.http.HttpException;
|
||||||
|
import org.jclouds.http.HttpRequest;
|
||||||
|
import org.jclouds.http.HttpRequestFilter;
|
||||||
|
import org.jclouds.http.HttpUtils;
|
||||||
|
import org.jclouds.util.DateService;
|
||||||
|
|
||||||
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
|
import com.google.common.base.Function;
|
||||||
|
import com.google.common.base.Predicate;
|
||||||
|
import com.google.common.collect.Iterables;
|
||||||
|
import com.google.common.collect.Sets;
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
import com.google.inject.name.Named;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs the Azure Storage request. This will update timestamps at most once per second.
|
||||||
|
*
|
||||||
|
* @see <a href= "http://msdn.microsoft.com/en-us/library/dd179428.aspx" />
|
||||||
|
* @author Adrian Cole
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
public class SharedKeyAuthentication implements HttpRequestFilter {
|
||||||
|
private final String[] firstHeadersToSign = new String[] { "Content-MD5",
|
||||||
|
HttpHeaders.CONTENT_TYPE, HttpHeaders.DATE };
|
||||||
|
|
||||||
|
private final String account;
|
||||||
|
private byte[] key;
|
||||||
|
private final DateService dateService;
|
||||||
|
|
||||||
|
public final long BILLION = 1000000000;
|
||||||
|
private final AtomicReference<String> timeStamp;
|
||||||
|
private final AtomicLong trigger = new AtomicLong(System.nanoTime() + 1 * BILLION);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the time update service. Azure clocks need to be within 900 seconds of the request time.
|
||||||
|
* This method updates the clock every second. This is not performed per-request, as creation of
|
||||||
|
* the date object is a slow, synchronized command.
|
||||||
|
*/
|
||||||
|
synchronized void updateIfTimeOut() {
|
||||||
|
|
||||||
|
if (trigger.get() - System.nanoTime() <= 0) {
|
||||||
|
timeStamp.set(createNewStamp());
|
||||||
|
trigger.set(System.nanoTime() + 1 * BILLION);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is a hotspot when submitted concurrently, so be lazy.
|
||||||
|
// amazon is ok with up to 15 minutes off their time, so let's
|
||||||
|
// be as lazy as possible.
|
||||||
|
String createNewStamp() {
|
||||||
|
return dateService.rfc822DateFormat();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String timestampAsHeaderString() {
|
||||||
|
updateIfTimeOut();
|
||||||
|
return timeStamp.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public SharedKeyAuthentication(
|
||||||
|
@Named(AzureStorageConstants.PROPERTY_AZURESTORAGE_ACCOUNT) String account,
|
||||||
|
@Named(AzureStorageConstants.PROPERTY_AZURESTORAGE_KEY) String encodedKey,
|
||||||
|
DateService dateService) {
|
||||||
|
this.account = account;
|
||||||
|
this.key = HttpUtils.fromBase64String(encodedKey);
|
||||||
|
this.dateService = dateService;
|
||||||
|
timeStamp = new AtomicReference<String>(createNewStamp());
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpRequest filter(HttpRequest request) throws HttpException {
|
||||||
|
replaceDateHeader(request);
|
||||||
|
String toSign = createStringToSign(request);
|
||||||
|
calculateAndReplaceAuthHeader(request, toSign);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String createStringToSign(HttpRequest request) {
|
||||||
|
StringBuilder buffer = new StringBuilder();
|
||||||
|
// re-sign the request
|
||||||
|
appendMethod(request, buffer);
|
||||||
|
appendHttpHeaders(request, buffer);
|
||||||
|
appendCanonicalizedHeaders(request, buffer);
|
||||||
|
appendCanonicalizedResource(request, buffer);
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void calculateAndReplaceAuthHeader(HttpRequest request, String toSign)
|
||||||
|
throws HttpException {
|
||||||
|
String signature = signString(toSign);
|
||||||
|
request.getHeaders().replaceValues(HttpHeaders.AUTHORIZATION,
|
||||||
|
Collections.singletonList("SharedKey " + account + ":" + signature));
|
||||||
|
}
|
||||||
|
|
||||||
|
public String signString(String toSign) {
|
||||||
|
String signature;
|
||||||
|
try {
|
||||||
|
signature = HttpUtils.hmacSha256Base64(toSign, key);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new HttpException("error signing request", e);
|
||||||
|
}
|
||||||
|
return signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendMethod(HttpRequest request, StringBuilder toSign) {
|
||||||
|
toSign.append(request.getMethod()).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void replaceDateHeader(HttpRequest request) {
|
||||||
|
request.getHeaders().replaceValues(HttpHeaders.DATE,
|
||||||
|
Collections.singletonList(timestampAsHeaderString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendCanonicalizedHeaders(HttpRequest request, StringBuilder toSign) {
|
||||||
|
|
||||||
|
// Retrieve all headers for the resource that begin with x-ms-, including the x-ms-date
|
||||||
|
// header.
|
||||||
|
Set<String> matchingHeaders = Sets.filter(request.getHeaders().keySet(),
|
||||||
|
new Predicate<String>() {
|
||||||
|
public boolean apply(String input) {
|
||||||
|
return input.startsWith("x-ms-");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert each HTTP header name to lowercase.
|
||||||
|
// Sort the container of headers lexicographically by header name, in ascending order.
|
||||||
|
SortedSet<String> lowercaseHeaders = Sets.newTreeSet(Iterables.transform(matchingHeaders,
|
||||||
|
new Function<String, String>() {
|
||||||
|
public String apply(String from) {
|
||||||
|
return from.toLowerCase();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (String header : lowercaseHeaders) {
|
||||||
|
// Combine headers with the same name into one header. The resulting header should be a
|
||||||
|
// name-value pair of the format "header-name:comma-separated-value-list", without any
|
||||||
|
// white
|
||||||
|
// space between values.
|
||||||
|
toSign.append(header).append(":");
|
||||||
|
// Trim any white space around the colon in the header.
|
||||||
|
// TODO: not sure why there would be...
|
||||||
|
for (String value : request.getHeaders().get(header))
|
||||||
|
// Replace any breaking white space with a single space.
|
||||||
|
toSign.append(value.replaceAll("\r?\n", " ")).append(",");
|
||||||
|
toSign.deleteCharAt(toSign.lastIndexOf(","));
|
||||||
|
// Finally, append a new line character to each canonicalized header in the resulting list.
|
||||||
|
// Construct the CanonicalizedHeaders string by concatenating all headers in this list into
|
||||||
|
// a
|
||||||
|
// single string.
|
||||||
|
toSign.append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendHttpHeaders(HttpRequest request, StringBuilder toSign) {
|
||||||
|
for (String header : firstHeadersToSign)
|
||||||
|
toSign.append(valueOrEmpty(request.getHeaders().get(header))).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
void appendCanonicalizedResource(HttpRequest request, StringBuilder toSign) {
|
||||||
|
|
||||||
|
// 1. Beginning with an empty string (""), append a forward slash (/), followed by the name of
|
||||||
|
// the account that owns the resource being accessed.
|
||||||
|
toSign.append("/").append(account);
|
||||||
|
appendUriPath(request, toSign);
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
void appendUriPath(HttpRequest request, StringBuilder toSign) {
|
||||||
|
// 2. Append the resource's encoded URI path
|
||||||
|
toSign.append(request.getEndpoint().getRawPath());
|
||||||
|
|
||||||
|
// If the request URI addresses a component of the
|
||||||
|
// resource, append the appropriate query string. The query string should include the question
|
||||||
|
// mark and the comp parameter (for example, ?comp=metadata). No other parameters should be
|
||||||
|
// included on the query string.
|
||||||
|
if (request.getEndpoint().getQuery() != null) {
|
||||||
|
// TODO: determine what components of the query string are really needed.
|
||||||
|
toSign.append("?").append(request.getEndpoint().getQuery());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String valueOrEmpty(Collection<String> collection) {
|
||||||
|
return (collection != null && collection.size() >= 1) ? collection.iterator().next() : "";
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package org.jclouds.azure.storage.reference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration properties and constants used in Azure Storage connections.
|
||||||
|
*
|
||||||
|
* @author Adrian Cole
|
||||||
|
*/
|
||||||
|
public interface AzureStorageConstants {
|
||||||
|
public static final String PROPERTY_AZURESTORAGE_ACCOUNT = "jclouds.azure.storage.account";
|
||||||
|
public static final String PROPERTY_AZURESTORAGE_KEY = "jclouds.azure.storage.key";
|
||||||
|
}
|
|
@ -0,0 +1,77 @@
|
||||||
|
package org.jclouds.azure.storage.filters;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
import javax.ws.rs.GET;
|
||||||
|
import javax.ws.rs.Path;
|
||||||
|
import static org.testng.Assert.assertTrue;
|
||||||
|
import org.jclouds.azure.storage.reference.AzureStorageConstants;
|
||||||
|
import org.jclouds.concurrent.WithinThreadExecutorService;
|
||||||
|
import org.jclouds.concurrent.config.ExecutorServiceModule;
|
||||||
|
import org.jclouds.http.config.JavaUrlHttpCommandExecutorServiceModule;
|
||||||
|
import org.jclouds.logging.log4j.config.Log4JLoggingModule;
|
||||||
|
import org.jclouds.rest.Query;
|
||||||
|
import org.jclouds.rest.RequestFilters;
|
||||||
|
import org.jclouds.rest.RestClientFactory;
|
||||||
|
import org.jclouds.rest.config.JaxrsModule;
|
||||||
|
import org.testng.annotations.BeforeClass;
|
||||||
|
import org.testng.annotations.Test;
|
||||||
|
|
||||||
|
import com.google.inject.AbstractModule;
|
||||||
|
import com.google.inject.Guice;
|
||||||
|
import com.google.inject.Injector;
|
||||||
|
import com.google.inject.name.Names;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests behavior of {@code JaxrsAnnotationProcessor}
|
||||||
|
*
|
||||||
|
* @author Adrian Cole
|
||||||
|
*/
|
||||||
|
@Test(groups = "live", testName = "azure.SharedKeyAuthenticationLiveTest")
|
||||||
|
public class SharedKeyAuthenticationLiveTest {
|
||||||
|
|
||||||
|
@RequestFilters(SharedKeyAuthentication.class)
|
||||||
|
public interface IntegrationTestClient {
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/")
|
||||||
|
@Query(key = "comp", value = "list")
|
||||||
|
String authenticate();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static final String sysAzureStorageAccount = System
|
||||||
|
.getProperty(AzureStorageConstants.PROPERTY_AZURESTORAGE_ACCOUNT);
|
||||||
|
protected static final String sysAzureStorageKey = System
|
||||||
|
.getProperty(AzureStorageConstants.PROPERTY_AZURESTORAGE_KEY);
|
||||||
|
private Injector injector;
|
||||||
|
private IntegrationTestClient client;
|
||||||
|
private String uri;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testAuthentication() throws Exception {
|
||||||
|
String response = client.authenticate();
|
||||||
|
assertTrue(response.contains(uri), String.format("expected %s to contain %s", response, uri));
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeClass
|
||||||
|
void setupFactory() {
|
||||||
|
injector = Guice.createInjector(new AbstractModule() {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void configure() {
|
||||||
|
bindConstant().annotatedWith(
|
||||||
|
Names.named(AzureStorageConstants.PROPERTY_AZURESTORAGE_ACCOUNT)).to(
|
||||||
|
sysAzureStorageAccount);
|
||||||
|
bindConstant().annotatedWith(
|
||||||
|
Names.named(AzureStorageConstants.PROPERTY_AZURESTORAGE_KEY)).to(
|
||||||
|
sysAzureStorageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
}, new JaxrsModule(), new Log4JLoggingModule(), new ExecutorServiceModule(
|
||||||
|
new WithinThreadExecutorService()), new JavaUrlHttpCommandExecutorServiceModule());
|
||||||
|
RestClientFactory factory = injector.getInstance(RestClientFactory.class);
|
||||||
|
uri = "http://" + sysAzureStorageAccount + ".blob.core.windows.net";
|
||||||
|
client = factory.create(URI.create(uri), IntegrationTestClient.class);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,140 @@
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* Copyright (C) 2009 Global Cloud Specialists, Inc. <info@globalcloudspecialists.com>
|
||||||
|
*
|
||||||
|
* ====================================================================
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you 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 org.jclouds.azure.storage.filters;
|
||||||
|
|
||||||
|
import static org.testng.Assert.assertEquals;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
import javax.ws.rs.HttpMethod;
|
||||||
|
import javax.ws.rs.core.HttpHeaders;
|
||||||
|
|
||||||
|
import org.jclouds.azure.storage.reference.AzureStorageConstants;
|
||||||
|
import org.jclouds.http.HttpRequest;
|
||||||
|
import org.jclouds.http.HttpUtils;
|
||||||
|
import org.jclouds.util.DateService;
|
||||||
|
import org.testng.annotations.BeforeClass;
|
||||||
|
import org.testng.annotations.DataProvider;
|
||||||
|
import org.testng.annotations.Test;
|
||||||
|
|
||||||
|
import com.google.inject.AbstractModule;
|
||||||
|
import com.google.inject.Guice;
|
||||||
|
import com.google.inject.Injector;
|
||||||
|
import com.google.inject.name.Names;
|
||||||
|
|
||||||
|
@Test(groups = "unit", testName = "azurestorage.SharedKeyAuthenticationTest")
|
||||||
|
public class SharedKeyAuthenticationTest {
|
||||||
|
|
||||||
|
private static final String KEY = HttpUtils.toBase64String("bar".getBytes());
|
||||||
|
private static final String ACCOUNT = "foo";
|
||||||
|
private Injector injector;
|
||||||
|
private SharedKeyAuthentication filter;
|
||||||
|
|
||||||
|
@DataProvider(parallel = true)
|
||||||
|
public Object[][] dataProvider() {
|
||||||
|
return new Object[][] {
|
||||||
|
{ new HttpRequest(
|
||||||
|
HttpMethod.PUT,
|
||||||
|
URI
|
||||||
|
.create("http://"
|
||||||
|
+ ACCOUNT
|
||||||
|
+ ".blob.core.windows.net/movies/MOV1.avi?comp=block&blockid=BlockId1&timeout=60")) },
|
||||||
|
{ new HttpRequest(HttpMethod.PUT, URI.create("http://" + ACCOUNT
|
||||||
|
+ ".blob.core.windows.net/movies/MOV1.avi?comp=blocklist&timeout=120")) },
|
||||||
|
{ new HttpRequest(HttpMethod.GET, URI.create("http://" + ACCOUNT
|
||||||
|
+ ".blob.core.windows.net/movies/MOV1.avi")) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOTE this test is dependent on how frequently the timestamp updates. At the time of writing,
|
||||||
|
* this was once per second. If this timestamp update interval is increased, it could make this
|
||||||
|
* test appear to hang for a long time.
|
||||||
|
*/
|
||||||
|
@Test(threadPoolSize = 3, dataProvider = "dataProvider", timeOut = 3000)
|
||||||
|
void testIdempotent(HttpRequest request) {
|
||||||
|
filter.filter(request);
|
||||||
|
String signature = request.getFirstHeaderOrNull(HttpHeaders.AUTHORIZATION);
|
||||||
|
String date = request.getFirstHeaderOrNull(HttpHeaders.DATE);
|
||||||
|
int iterations = 1;
|
||||||
|
while (filter.filter(request).getFirstHeaderOrNull(HttpHeaders.DATE).equals(date)) {
|
||||||
|
iterations++;
|
||||||
|
assertEquals(signature, request.getFirstHeaderOrNull(HttpHeaders.AUTHORIZATION));
|
||||||
|
}
|
||||||
|
System.out.printf("%s: %d iterations before the timestamp updated %n", Thread.currentThread()
|
||||||
|
.getName(), iterations);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAclQueryStringRoot() {
|
||||||
|
URI host = URI.create("http://" + ACCOUNT + ".blob.core.windows.net/?comp=list");
|
||||||
|
HttpRequest request = new HttpRequest(HttpMethod.GET, host);
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
filter.appendUriPath(request, builder);
|
||||||
|
assertEquals(builder.toString(), "/?comp=list");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testAclQueryStringRelative() {
|
||||||
|
URI host = URI.create("http://" + ACCOUNT
|
||||||
|
+ ".blob.core.windows.net/mycontainer?restype=container");
|
||||||
|
HttpRequest request = new HttpRequest(HttpMethod.GET, host);
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
filter.appendUriPath(request, builder);
|
||||||
|
assertEquals(builder.toString(), "/mycontainer?restype=container");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUpdatesOnlyOncePerSecond() throws NoSuchMethodException, InterruptedException {
|
||||||
|
// filter.createNewStamp();
|
||||||
|
String timeStamp = filter.timestampAsHeaderString();
|
||||||
|
// replay(filter);
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
filter.updateIfTimeOut();
|
||||||
|
assert timeStamp.equals(filter.timestampAsHeaderString());
|
||||||
|
Thread.sleep(1000);
|
||||||
|
assert !timeStamp.equals(filter.timestampAsHeaderString());
|
||||||
|
// verify(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* before class, as we need to ensure that the filter is threadsafe.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@BeforeClass
|
||||||
|
protected void createFilter() {
|
||||||
|
injector = Guice.createInjector(new AbstractModule() {
|
||||||
|
|
||||||
|
protected void configure() {
|
||||||
|
bindConstant().annotatedWith(
|
||||||
|
Names.named(AzureStorageConstants.PROPERTY_AZURESTORAGE_ACCOUNT)).to(ACCOUNT);
|
||||||
|
bindConstant().annotatedWith(
|
||||||
|
Names.named(AzureStorageConstants.PROPERTY_AZURESTORAGE_KEY)).to(KEY);
|
||||||
|
bind(DateService.class);
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
filter = injector.getInstance(SharedKeyAuthentication.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue