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:
adrian.f.cole 2009-09-01 23:07:35 +00:00
parent a0192989d4
commit e3b3ca5e6f
4 changed files with 428 additions and 0 deletions

View File

@ -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() : "";
}
}

View File

@ -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";
}

View File

@ -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);
}
}

View File

@ -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);
}
}