io.helidon.security.provider.httpsign.HttpSignProvider Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of helidon-security-provider-http-signature Show documentation
Show all versions of helidon-security-provider-http-signature Show documentation
HTTP Signatures authentication and outbound security provider
/*
* Copyright (c) 2018 Oracle and/or its affiliates. All rights reserved.
*
* Licensed 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 io.helidon.security.provider.httpsign;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import io.helidon.common.CollectionsHelper;
import io.helidon.config.Config;
import io.helidon.security.AuthenticationResponse;
import io.helidon.security.EndpointConfig;
import io.helidon.security.OutboundSecurityResponse;
import io.helidon.security.Principal;
import io.helidon.security.ProviderRequest;
import io.helidon.security.SecurityEnvironment;
import io.helidon.security.SecurityResponse;
import io.helidon.security.Subject;
import io.helidon.security.SubjectType;
import io.helidon.security.providers.OutboundConfig;
import io.helidon.security.providers.OutboundTarget;
import io.helidon.security.spi.AuthenticationProvider;
import io.helidon.security.spi.OutboundSecurityProvider;
/**
* A provider that can authenticate incoming requests based on HTTP signature of header fields, and
* can create signatures for outbound requests.
*/
public final class HttpSignProvider implements AuthenticationProvider, OutboundSecurityProvider {
static final String ALGORITHM_HMAC = "hmac-sha256";
static final String ALGORITHM_RSA = "rsa-sha256";
static final SignedHeadersConfig DEFAULT_REQUIRED_HEADERS = SignedHeadersConfig.builder()
.defaultConfig(SignedHeadersConfig.HeadersConfig
.create(CollectionsHelper.listOf("date", SignedHeadersConfig.REQUEST_TARGET)))
.config("get", SignedHeadersConfig.HeadersConfig
.create(CollectionsHelper.listOf("date", SignedHeadersConfig.REQUEST_TARGET, "host"),
CollectionsHelper.listOf("authorization")))
.config("head", SignedHeadersConfig.HeadersConfig
.create(CollectionsHelper.listOf("date", SignedHeadersConfig.REQUEST_TARGET, "host"),
CollectionsHelper.listOf("authorization")))
.config("delete", SignedHeadersConfig.HeadersConfig
.create(CollectionsHelper.listOf("date", SignedHeadersConfig.REQUEST_TARGET, "host"),
CollectionsHelper.listOf("authorization")))
.config("put", SignedHeadersConfig.HeadersConfig
.create(CollectionsHelper.listOf("date", SignedHeadersConfig.REQUEST_TARGET, "host"),
CollectionsHelper.listOf("authorization")))
.config("post", SignedHeadersConfig.HeadersConfig
.create(CollectionsHelper.listOf("date", SignedHeadersConfig.REQUEST_TARGET, "host"),
CollectionsHelper.listOf("authorization")))
.build();
static final String ATTRIB_NAME_KEY_ID = HttpSignProvider.class.getName() + ".keyId";
private final boolean optional;
private final String realm;
private final Set acceptHeaders;
private final SignedHeadersConfig inboundRequiredHeaders;
private final Map inboundKeys;
private final OutboundConfig outboundConfig;
// cache of target name to a signature configuration for outbound calls
private final Map targetKeys = new HashMap<>();
private HttpSignProvider(Builder builder) {
this.optional = builder.optional;
this.realm = builder.realm;
this.acceptHeaders = (
builder.acceptHeaders.isEmpty()
? EnumSet.of(HttpSignHeader.SIGNATURE, HttpSignHeader.AUTHORIZATION)
: EnumSet.copyOf(builder.acceptHeaders));
this.inboundRequiredHeaders = builder.inboundRequiredHeaders;
this.inboundKeys = builder.inboundKeys;
this.outboundConfig = builder.outboundConfig;
outboundConfig.getTargets().forEach(target -> target.getConfig().ifPresent(targetConfig -> {
OutboundTargetDefinition outboundTargetDefinition = targetConfig.get("signature").as(OutboundTargetDefinition.class);
targetKeys.put(target.getName(), outboundTargetDefinition);
}));
}
/**
* Create a new instance of this provider from configuration.
*
* @param config config located at this provider, expects "http-signature" to be a child
* @return provider configured from config
*/
public static HttpSignProvider fromConfig(Config config) {
return builder().fromConfig(config).build();
}
/**
* Create a builder to build this provider.
*
* @return builder instance
*/
public static Builder builder() {
return new Builder();
}
@Override
public CompletionStage authenticate(ProviderRequest providerRequest) {
Map> headers = providerRequest.getEnv().getHeaders();
if ((headers.get("Signature") != null) && acceptHeaders.contains(HttpSignHeader.SIGNATURE)) {
return CompletableFuture
.supplyAsync(() -> signatureHeader(headers.get("Signature"), providerRequest.getEnv()),
providerRequest.getContext().getExecutorService());
} else if ((headers.get("Authorization") != null) && acceptHeaders.contains(HttpSignHeader.AUTHORIZATION)) {
// TODO when authorization header in use and "authorization" is also a
// required header to be signed, we must either fail or ignore, as we cannot sign ourselves
return CompletableFuture
.supplyAsync(() -> authorizeHeader(providerRequest.getEnv()),
providerRequest.getContext().getExecutorService());
}
if (optional) {
return CompletableFuture.completedFuture(AuthenticationResponse.abstain());
}
return CompletableFuture
.completedFuture(AuthenticationResponse.failed("Missing header. Accepted headers: " + acceptHeaders));
}
private AuthenticationResponse authorizeHeader(SecurityEnvironment env) {
List authorization = env.getHeaders().get("Authorization");
AuthenticationResponse response = null;
// attempt to validate each authorization, first one that succeeds will finish processing and return
for (String authorizationValue : authorization) {
if (authorizationValue.toLowerCase().startsWith("signature ")) {
response = signatureHeader(CollectionsHelper.listOf(authorizationValue.substring("singature ".length())), env);
if (response.getStatus().isSuccess()) {
// that was a good header, let's return the response
return response;
}
}
}
// we have reached the end - all headers validated, none fit, fail or abstain
if (optional) {
return AuthenticationResponse.abstain();
}
// challenge
return challenge(env, (null == response)
? "No Signature authorization header"
: response.getDescription().orElse("Unknown problem"));
}
private AuthenticationResponse challenge(SecurityEnvironment env, String description) {
return AuthenticationResponse.builder()
.responseHeader("WWW-Authenticate", "Signature realm=\""
+ realm
+ ",headers=\""
+ headersForMethod(env.getMethod())
+ "\"")
.status(SecurityResponse.SecurityStatus.FAILURE)
.statusCode(401)
.description(description)
.build();
}
private String headersForMethod(String method) {
return String.join(" ", inboundRequiredHeaders.getHeaders(method.toLowerCase()));
}
private AuthenticationResponse signatureHeader(List signatures,
SecurityEnvironment env) {
/*
Signature keyId="rsa-key-1",algorithm="rsa-sha256",
headers="(request-target) host date digest content-length",
signature="Base64(RSA-SHA256(signing string))"
*/
String lastError = signatures.isEmpty() ? "No signature values for Signature header" : null;
for (String signature : signatures) {
HttpSignature httpSignature = HttpSignature.fromHeader(signature);
Optional validate = httpSignature.validate();
if (validate.isPresent()) {
lastError = validate.get();
} else {
//this is a valid signature object, let's validate against key
InboundClientDefinition clientDefinition = inboundKeys.get(httpSignature.getKeyId());
if (null == clientDefinition) {
lastError = "Client definition for client with key " + httpSignature.getKeyId() + " not found";
continue;
}
//now we have a signature with a valid keyId - if validation fails, we fail
return validateSignature(env, httpSignature, clientDefinition);
}
}
if (optional) {
return AuthenticationResponse.abstain();
} else {
return AuthenticationResponse.failed(lastError);
}
}
private AuthenticationResponse validateSignature(SecurityEnvironment env,
HttpSignature httpSignature,
InboundClientDefinition clientDefinition) {
// validate algorithm
Optional validationResult = httpSignature.validate(env,
clientDefinition,
inboundRequiredHeaders.getHeaders(env.getMethod(),
env.getHeaders()));
if (validationResult.isPresent()) {
return AuthenticationResponse.failed(validationResult.get());
}
Principal principal = Principal.builder()
.name(clientDefinition.getPrincipalName())
.addAttribute(ATTRIB_NAME_KEY_ID, clientDefinition.getKeyId())
.build();
Subject subject = Subject.builder()
.principal(principal)
.build();
if (clientDefinition.getSubjectType() == SubjectType.USER) {
return AuthenticationResponse.success(subject);
} else {
return AuthenticationResponse.successService(subject);
}
}
@Override
public boolean isOutboundSupported(ProviderRequest providerRequest,
SecurityEnvironment outboundEnv,
EndpointConfig outboundConfig) {
return this.outboundConfig.findTarget(outboundEnv).isPresent();
}
@Override
public CompletionStage outboundSecurity(ProviderRequest providerRequest,
SecurityEnvironment outboundEnv,
EndpointConfig outboundConfig) {
return CompletableFuture.supplyAsync(() -> signRequest(outboundEnv),
providerRequest.getContext().getExecutorService());
}
private OutboundSecurityResponse signRequest(SecurityEnvironment outboundEnv) {
Optional targetOpt = this.outboundConfig.findTarget(outboundEnv);
return targetOpt.map(target -> {
OutboundTargetDefinition targetConfig = this.targetKeys.computeIfAbsent(target.getName(), key -> target.getConfig()
.flatMap(config -> config.get("signature").asOptional(OutboundTargetDefinition.class))
.orElse(target.getCustomObject(OutboundTargetDefinition.class).orElseThrow(() -> new HttpSignatureException(
"Failed to find configuration for outbound signatures for target " + target.getName()))));
Map> newHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
newHeaders.putAll(outboundEnv.getHeaders());
HttpSignature signature = HttpSignature.sign(outboundEnv, targetConfig, newHeaders);
OutboundSecurityResponse.Builder builder = OutboundSecurityResponse.builder()
.requestHeaders(newHeaders)
.status(SecurityResponse.SecurityStatus.SUCCESS);
switch (targetConfig.getHeader()) {
case SIGNATURE:
builder.requestHeader("Signature", signature.toSignatureHeader());
break;
case AUTHORIZATION:
builder.requestHeader("Authorization", "Signature " + signature.toSignatureHeader());
break;
default:
throw new HttpSignatureException("Invalid header configuration: " + targetConfig.getHeader());
}
Map> headers = outboundEnv.getHeaders();
if (headers.containsKey("host")) {
builder.requestHeader("host", headers.get("host"));
}
if (headers.containsKey("date")) {
builder.requestHeader("date", headers.get("date"));
}
return builder.build();
}).orElse(OutboundSecurityResponse.empty());
}
/**
* Fluent API builder for this provider. Call {@link #build()} to create a provider instance.
*/
public static class Builder implements io.helidon.common.Builder {
private boolean optional = true;
private String realm = "prime";
private final Set acceptHeaders = EnumSet.noneOf(HttpSignHeader.class);
private SignedHeadersConfig inboundRequiredHeaders = SignedHeadersConfig.builder().build();
private OutboundConfig outboundConfig = OutboundConfig.builder().build();
private final Map inboundKeys = new HashMap<>();
@Override
public HttpSignProvider build() {
return new HttpSignProvider(this);
}
/**
* Create a builder from configuration.
*
* @param config Config located at http-signatures key
* @return builder instance configured from config
*/
public Builder fromConfig(Config config) {
acceptHeaders.addAll(config.get("headers").asList(HttpSignHeader.class, CollectionsHelper.listOf()));
optional = config.get("optional").asBoolean(false);
realm = config.get("realm").asString("prime");
inboundRequiredHeaders = config.get("sign-headers").as(SignedHeadersConfig.class, DEFAULT_REQUIRED_HEADERS);
outboundConfig = OutboundConfig.parseTargets(config);
config.get("inbound.keys")
.asList(InboundClientDefinition.class, CollectionsHelper.listOf())
.forEach(inbound -> inboundKeys.put(inbound.getKeyId(), inbound));
return this;
}
/**
* Add outbound targets to this builder.
* The targets are used to chose what to do for outbound communication.
* The targets should have {@link OutboundTargetDefinition} attached through
* {@link OutboundTarget.Builder#customObject(Class, Object)} to tell us how to sign
* the request.
*
* The same can be done through configuration:
*
* {
* name = "http-signatures"
* class = "HttpSignProvider"
* http-signatures {
* targets: [
* {
* name = "service2"
* hosts = ["localhost"]
* paths = ["/service2/.*"]
*
* # This configures the {@link OutboundTargetDefinition}
* signature {
* key-id = "service1"
* hmac.secret = "${CLEAR=password}"
* }
* }]
* }
* }
*
*
* @param targets targets to select correct outbound security
* @return updated builder instance
*/
public Builder outbound(OutboundConfig targets) {
this.outboundConfig = targets;
return this;
}
/**
* Add inbound configuration. This is used to validate signature and authenticate the
* party.
*
* The same can be done through configuration:
*
* {
* name = "http-signatures"
* class = "HttpSignProvider"
* http-signatures {
* inbound {
* # This configures the {@link InboundClientDefinition}
* keys: [
* {
* key-id = "service1"
* hmac.secret = "${CLEAR=password}"
* }]
* }
* }
* }
*
*
* @param client a single client configuration for inbound communication
* @return updated builder instance
*/
public Builder addInbound(InboundClientDefinition client) {
this.inboundKeys.put(client.getKeyId(), client);
return this;
}
/**
* Override the default inbound required headers (e.g. headers that MUST be signed and
* headers that MUST be signed IF present).
*
* Defaults:
*
* - get, head, delete methods: date, (request-target), host are mandatory; authorization if present (unless we are
* creating/validating the {@link HttpSignHeader#AUTHORIZATION} ourselves
* - put, post: same as above, with addition of: content-length, content-type and digest if present
*
- for other methods: date, (request-target)
*
* Note that this provider DOES NOT validate the "Digest" HTTP header, only the signature.
*
* @param inboundRequiredHeaders headers configuration
* @return updated builder instance
*/
public Builder inboundRequiredHeaders(SignedHeadersConfig inboundRequiredHeaders) {
this.inboundRequiredHeaders = inboundRequiredHeaders;
return this;
}
/**
* Add a header that is validated on inbound requests. Provider may support more than
* one header to validate.
*
* @param header header to look for signature
* @return updated builder instance
*/
public Builder addAcceptHeader(HttpSignHeader header) {
this.acceptHeaders.add(header);
return this;
}
/**
* Set whether the signature is optional. If set to true (default), this provider will
* {@link SecurityResponse.SecurityStatus#ABSTAIN} from this request if signature is not
* present. If set to false, this provider will {@link SecurityResponse.SecurityStatus#FAILURE fail}
* if signature is not present.
*
* @param optional true for optional singatures
* @return updated builder instance
*/
public Builder optional(boolean optional) {
this.optional = optional;
return this;
}
/**
* Realm to use for challenging inbound requests that do not have "Authorization" header
* in case header is {@link HttpSignHeader#AUTHORIZATION} and singatures are not optional.
*
* @param realm realm to challenge with, defautls to "prime"
* @return updated builder instance
*/
public Builder realm(String realm) {
this.realm = realm;
return this;
}
}
}