/*************************************************************************
* Copyright 2016 Hewlett-Packard Enterprise, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; version 3 of the License.
*
* This program 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
* Please contact Eucalyptus Systems, Inc., 6755 Hollister Ave., Goleta
* CA 93117, USA or visit http://www.eucalyptus.com/licenses/ if you need
* additional information or have any questions.
************************************************************************/
package com.eucalyptus.objectstorage.pipeline.auth;
import com.eucalyptus.auth.login.SecurityContext;
import com.eucalyptus.component.ComponentIds;
import com.eucalyptus.crypto.util.SecurityParameter;
import com.eucalyptus.http.MappingHttpRequest;
import com.eucalyptus.objectstorage.ObjectStorage;
import com.eucalyptus.objectstorage.exceptions.s3.*;
import com.eucalyptus.objectstorage.util.OSGUtil;
import com.eucalyptus.objectstorage.util.ObjectStorageProperties;
import com.google.common.base.Strings;
import javaslang.control.Try.CheckedFunction;
import org.apache.log4j.Logger;
import org.jboss.netty.handler.codec.http.HttpHeaders;
import org.jboss.netty.handler.codec.http.HttpMethod;
import javax.security.auth.login.LoginException;
import java.util.*;
/**
* S3 V2 specific authentication utilities.
*/
final class S3V2Authentication {
private static final Logger LOG = Logger.getLogger(S3V4Authentication.class);
static final String AWS_V2_AUTH_TYPE = "AWS";
enum ExcludeFromSignature {
NONE, PATH, CONTENT_TYPE;
}
private S3V2Authentication() {
}
static void login(MappingHttpRequest request, Date signatureDate, String date, String canonicalizedAmzHeaders,
String accessKeyId, String signature, String securityToken) throws S3Exception {
login(request, accessKeyId, excludeOption -> {
String stringToSign = buildStringToSign(request, date, canonicalizedAmzHeaders, excludeOption);
return new ObjectStorageWrappedCredentials(request.getCorrelationId(),
signatureDate==null?null:signatureDate.getTime(), stringToSign, accessKeyId, signature, securityToken);
});
}
static ObjectStorageWrappedCredentials credentialsFor(CheckedFunction<ExcludeFromSignature, ObjectStorageWrappedCredentials> credsFn,
ExcludeFromSignature exclude) throws S3Exception {
try {
return credsFn.apply(exclude);
} catch (Throwable t) {
if (t instanceof S3Exception)
throw (S3Exception) t;
throw new InternalErrorException(t);
}
}
/**
* Attempts a login and retries sign a signed string that does not contain a path or Content-Type if the initial attempt fails.
*/
private static void login(MappingHttpRequest request, String accessKeyId,
CheckedFunction<ExcludeFromSignature, ObjectStorageWrappedCredentials> credsFn) throws S3Exception {
// Build credentials that includes path
ObjectStorageWrappedCredentials creds = credentialsFor(credsFn, ExcludeFromSignature.NONE);
try {
SecurityContext.getLoginContext(creds).login();
} catch (LoginException ex) {
if (ex.getMessage().contains("The AWS Access Key Id you provided does not exist in our records"))
throw new InvalidAccessKeyIdException(accessKeyId);
if (request.getUri().startsWith(ComponentIds.lookup(ObjectStorage.class).getServicePath()) || request.getUri().startsWith
(ObjectStorageProperties.LEGACY_WALRUS_SERVICE_PATH)) {
try {
LOG.debug("Fallback to login without resource path");
// Build credentials for a string to sign that excludes the resource path
creds = credentialsFor(credsFn, ExcludeFromSignature.PATH);
SecurityContext.getLoginContext(creds).login();
} catch (Exception ex2) {
LOG.debug("CorrelationId: " + request.getCorrelationId() + " Authentication failed due to signature match issue:", ex2);
throw new SignatureDoesNotMatchException(creds.accessKeyId, creds.getLoginData(), creds.signature);
}
} else if (request.getMethod() == HttpMethod.GET || request.getMethod() == HttpMethod.HEAD) {
// Build credentials for a string to sign that excludes the Content-Type
try {
LOG.debug("Fallback to login without content-type");
creds = credentialsFor(credsFn, ExcludeFromSignature.CONTENT_TYPE);
SecurityContext.getLoginContext(creds).login();
} catch (Exception ex2) {
LOG.debug("CorrelationId: " + request.getCorrelationId() + " Authentication failed due to signature match issue:", ex2);
throw new SignatureDoesNotMatchException(creds.accessKeyId, creds.getLoginData(), creds.signature);
}
} else {
throw new SignatureDoesNotMatchException(creds.accessKeyId, creds.getLoginData(), creds.signature);
}
} catch (Exception e) {
LOG.warn("CorrelationId: " + request.getCorrelationId() + " Unexpected failure trying to authenticate request", e);
throw new InternalErrorException(e);
}
}
/*
* @param if exclude is ExcludeFromSignature.CONTENT_TYPE, removes the content type from the address string if found
*/
private static String buildStringToSign(MappingHttpRequest request, String date, String canonicalizedAmzHeaders, ExcludeFromSignature exclude)
throws S3Exception {
String contentMd5 = request.getHeader(HttpHeaders.Names.CONTENT_MD5);
contentMd5 = contentMd5 == null ? "" : contentMd5;
String contentType = request.getHeader(HttpHeaders.Names.CONTENT_TYPE);
contentType = contentType == null ? "" : contentType;
String address = buildCanonicalResource(request, exclude);
StringBuilder sb = new StringBuilder(request.getMethod().getName());
sb.append("\n").append(contentMd5).append("\n");
if (exclude != ExcludeFromSignature.CONTENT_TYPE)
sb.append(contentType);
sb.append("\n").append(date).append("\n").append(canonicalizedAmzHeaders).append(address);
return sb.toString();
}
/**
* AWS S3-spec address string, which includes the query parameters
*
* @param if exclude is ExcludeFromSignature.PATH, removes the service path from the address string if found and if the request is path-style
* @see <a href="http://docs.aws.amazon.com/AmazonS3/latest/dev/RESTAuthentication.html#ConstructingTheCanonicalizedResourceElement">AWS
* Docs</a>
*/
static String buildCanonicalResource(MappingHttpRequest httpRequest, ExcludeFromSignature exclude) throws S3Exception {
/*
There are two modes: dns-style and path-style. dns-style has the bucket name in the HOST header path-style has
the bucket name in the request path.
If using DNS-style, we assume the key is the path, no service path necessary or allowed If using path-style,
there may be service path as well
that prefixes the bucket name (e.g. /services/objectstorage/bucket/key)
*/
try {
String addr = httpRequest.getUri();
String osgServicePath = ComponentIds.lookup(ObjectStorage.class).getServicePath();
String key;
StringBuilder addrString = new StringBuilder();
// Normalize the URI
boolean foundName = false;
String hostBucket;
if ((hostBucket = OSGUtil.getBucketFromHostHeader(httpRequest)) != null) {
// dns-style request
foundName = true;
addrString.append("/").append(hostBucket);
}
if (!foundName) {
// path-style request (or service request that won't have a bucket anyway)
if (exclude == ExcludeFromSignature.PATH) {
if (addr.startsWith(osgServicePath)) {
addr = addr.substring(osgServicePath.length(), addr.length());
} else if (addr.startsWith(ObjectStorageProperties.LEGACY_WALRUS_SERVICE_PATH)) {
addr = addr.substring(ObjectStorageProperties.LEGACY_WALRUS_SERVICE_PATH.length(), addr.length());
}
}
}
// Get the path part, up to the ?
key = addr.split("\\?", 2)[0];
if (!Strings.isNullOrEmpty(key)) {
addrString.append(key);
} else {
addrString.append("/");
}
List<String> canonicalSubresources = new ArrayList<>();
for (String queryParam : httpRequest.getParameters().keySet()) {
try {
ObjectStorageProperties.SubResource.valueOf(queryParam);
canonicalSubresources.add(queryParam);
} catch (IllegalArgumentException e) {
// Skip. Not in the set.
}
try {
if (ObjectStorageProperties.ResponseHeaderOverrides.fromString(queryParam) != null) {
canonicalSubresources.add(queryParam);
}
} catch (IllegalArgumentException e) {
// Skip. Not in the set.
}
}
if (canonicalSubresources.size() > 0) {
Collections.sort(canonicalSubresources);
String value;
addrString.append("?");
// Add resources to canonical string
for (String subResource : canonicalSubresources) {
value = httpRequest.getParameters().get(subResource);
addrString.append(subResource);
// Query values are not URL-decoded, the signature should have them exactly as in the URI
if (!Strings.isNullOrEmpty(value)) {
addrString.append("=").append(value);
}
addrString.append("&");
}
// Remove trailng '&' if found
if (addrString.charAt(addrString.length() - 1) == '&') {
addrString.deleteCharAt(addrString.length() - 1);
}
}
return addrString.toString();
} catch (S3Exception e) {
throw e;
} catch (Exception e) {
// Anything unexpected...
throw new InternalErrorException(e);
}
}
/**
* Query params are included in cases of Query-String/Presigned-url auth where they are considered just like headers.
*/
static String buildCanonicalHeaders(MappingHttpRequest httpRequest, boolean includeQueryParams) {
String result = "";
Set<String> headerNames = httpRequest.getHeaderNames();
TreeMap<String, String> amzHeaders = new TreeMap<>();
for (String headerName : headerNames) {
String headerNameString = headerName.toLowerCase().trim();
if (headerNameString.startsWith("x-amz-")) {
String value = httpRequest.getHeader(headerName).trim();
String[] parts = value.split("\n");
value = "";
for (String part : parts) {
part = part.trim();
value += part + " ";
}
value = value.trim();
if (amzHeaders.containsKey(headerNameString)) {
String oldValue = amzHeaders.remove(headerNameString);
oldValue += "," + value;
amzHeaders.put(headerNameString, oldValue);
} else {
amzHeaders.put(headerNameString, value);
}
}
}
if (includeQueryParams) {
// For query-string auth, header values may include 'x-amz-*' that need to be signed
for (String paramName : httpRequest.getParameters().keySet()) {
processHeaderValue(paramName, httpRequest.getParameters().get(paramName), amzHeaders);
}
}
// Build the canonical string
for (Map.Entry<String, String> entry : amzHeaders.entrySet()) {
result += entry.getKey() + ":" + entry.getValue() + "\n";
}
return result;
}
/**
* Gets and validates a date obtained from an Expires parameter.
*/
static String getAndValidateExpiresFromParameters(Map<String, String> parameters) throws InvalidSecurityException, AccessDeniedException {
String expires = parameters.remove(SecurityParameter.Expires.toString().toLowerCase());
if (expires == null)
throw new InvalidSecurityException("Expires parameter must be specified.");
// Assert not expired
Long expireTime;
try {
expireTime = Long.parseLong(expires);
} catch (NumberFormatException e) {
throw new AccessDeniedException(null, "Invalid Expires parameter.");
}
Long currentTime = new Date().getTime() / 1000;
if (currentTime > expireTime)
throw new AccessDeniedException(null, "Cannot process request. Expired.");
return expires;
}
private static void processHeaderValue(String name, String value, Map<String, String> aggregatingMap) {
String headerNameString = name.toLowerCase().trim();
if (headerNameString.startsWith("x-amz-")) {
value = value.trim();
String[] parts = value.split("\n");
value = "";
for (String part : parts) {
part = part.trim();
value += part + " ";
}
value = value.trim();
if (aggregatingMap.containsKey(headerNameString)) {
String oldValue = aggregatingMap.remove(headerNameString);
oldValue += "," + value;
aggregatingMap.put(headerNameString, oldValue);
} else {
aggregatingMap.put(headerNameString, value);
}
}
}
}