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