diff --git a/security/providers/http-auth/docs/full.yaml b/security/providers/http-auth/docs/full.yaml index 330769dc99e..d031b95ff7b 100644 --- a/security/providers/http-auth/docs/full.yaml +++ b/security/providers/http-auth/docs/full.yaml @@ -19,6 +19,9 @@ security: # Http basic and digest authentication # https://tools.ietf.org/html/rfc2617 - http-basic-auth: + # If set to true, provider will abstain rather than fail if authentication fails + # defaults to false + optional: true realm: "helidon" # Can be USER or SERVICE # defaults to USER @@ -32,6 +35,9 @@ security: password: "${AES=3XQ8A1RszE9JbXl+lUnnsX0gakuqjnTyp8YJWIAU1D3SiM2TaSnxd6U0/LjrdJYv}" roles: ["user"] - http-digest-auth: + # If set to true, provider will abstain rather than fail if authentication fails + # defaults to false + optional: true realm: "helidon" # Can be USER or SERVICE # defaults to USER diff --git a/security/providers/http-auth/src/main/java/io/helidon/security/providers/httpauth/HttpBasicAuthProvider.java b/security/providers/http-auth/src/main/java/io/helidon/security/providers/httpauth/HttpBasicAuthProvider.java index 6ebb4bd5f95..4110343df37 100644 --- a/security/providers/http-auth/src/main/java/io/helidon/security/providers/httpauth/HttpBasicAuthProvider.java +++ b/security/providers/http-auth/src/main/java/io/helidon/security/providers/httpauth/HttpBasicAuthProvider.java @@ -38,6 +38,7 @@ import io.helidon.security.Role; import io.helidon.security.SecurityContext; 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.common.OutboundConfig; @@ -71,6 +72,7 @@ public class HttpBasicAuthProvider extends SynchronousProvider implements Authen static final Pattern CREDENTIAL_PATTERN = Pattern.compile("(.*?):(.*)"); private final List userStores; + private final boolean optional; private final String realm; private final SubjectType subjectType; private final OutboundConfig outboundConfig; @@ -78,6 +80,7 @@ public class HttpBasicAuthProvider extends SynchronousProvider implements Authen HttpBasicAuthProvider(Builder builder) { this.userStores = new LinkedList<>(builder.userStores); + this.optional = builder.optional; this.realm = builder.realm; this.subjectType = builder.subjectType; this.outboundConfig = builder.outboundBuilder.build(); @@ -208,14 +211,15 @@ protected AuthenticationResponse syncAuthenticate(ProviderRequest providerReques List authorizationHeader = headers.get(HEADER_AUTHENTICATION); if (null == authorizationHeader) { - return fail("No " + HEADER_AUTHENTICATION + " header"); + return failOrAbstain("No " + HEADER_AUTHENTICATION + " header"); } return authorizationHeader.stream() .filter(header -> header.toLowerCase().startsWith(BASIC_PREFIX)) .findFirst() .map(this::validateBasicAuth) - .orElseGet(() -> fail("Authorization header does not contain basic authentication: " + authorizationHeader)); + .orElseGet(() -> + failOrAbstain("Authorization header does not contain basic authentication: " + authorizationHeader)); } private AuthenticationResponse validateBasicAuth(String basicAuthHeader) { @@ -226,13 +230,13 @@ private AuthenticationResponse validateBasicAuth(String basicAuthHeader) { usernameAndPassword = new String(Base64.getDecoder().decode(b64), StandardCharsets.UTF_8); } catch (IllegalArgumentException e) { // not a base64 encoded string - return fail("Basic authentication header with invalid content - not base64 encoded"); + return failOrAbstain("Basic authentication header with invalid content - not base64 encoded"); } Matcher matcher = CREDENTIAL_PATTERN.matcher(usernameAndPassword); if (!matcher.matches()) { LOGGER.finest(() -> "Basic authentication header with invalid content: " + usernameAndPassword); - return fail("Basic authentication header with invalid content"); + return failOrAbstain("Basic authentication header with invalid content"); } final String username = matcher.group(1); @@ -263,16 +267,23 @@ private AuthenticationResponse invalidUser() { // extracted to method to make sure we return the same message for invalid user and password // DO NOT change this - it is a security problem if the message differs, as it gives too much information // to potential attacker - return fail("Invalid username or password"); + return failOrAbstain("Invalid username or password"); } - private AuthenticationResponse fail(String message) { - return AuthenticationResponse.builder() - .statusCode(401) - .responseHeader(HEADER_AUTHENTICATION_REQUIRED, buildChallenge()) - .status(AuthenticationResponse.SecurityStatus.FAILURE) - .description(message) - .build(); + private AuthenticationResponse failOrAbstain(String message) { + if (optional) { + return AuthenticationResponse.builder() + .status(SecurityResponse.SecurityStatus.ABSTAIN) + .description(message) + .build(); + } else { + return AuthenticationResponse.builder() + .statusCode(401) + .responseHeader(HEADER_AUTHENTICATION_REQUIRED, buildChallenge()) + .status(AuthenticationResponse.SecurityStatus.FAILURE) + .description(message) + .build(); + } } private String buildChallenge() { @@ -299,6 +310,7 @@ public static final class Builder implements io.helidon.common.Builder userStores = new LinkedList<>(); private final OutboundConfig.Builder outboundBuilder = OutboundConfig.builder(); + private boolean optional = false; private String realm = "helidon"; private SubjectType subjectType = SubjectType.USER; @@ -311,6 +323,7 @@ private Builder() { * @return updated builder instance */ public Builder config(Config config) { + config.get("optional").asBoolean().ifPresent(this::optional); config.get("realm").asString().ifPresent(this::realm); config.get("principal-type").asString().as(SubjectType::valueOf).ifPresent(this::subjectType); @@ -409,6 +422,19 @@ public Builder realm(String realm) { return this; } + /** + * Whether authentication is required. + * By default, request will fail if the authentication cannot be verified. + * If set to false, request will process and this provider will abstain. + * + * @param optional whether authentication is optional (true) or required (false) + * @return updated builder instance + */ + public Builder optional(boolean optional) { + this.optional = optional; + return this; + } + /** * Add a new outbound target to configure identity propagation or explicit username/password. * diff --git a/security/providers/http-auth/src/main/java/io/helidon/security/providers/httpauth/HttpDigestAuthProvider.java b/security/providers/http-auth/src/main/java/io/helidon/security/providers/httpauth/HttpDigestAuthProvider.java index c99f5148843..0e00bf888d1 100644 --- a/security/providers/http-auth/src/main/java/io/helidon/security/providers/httpauth/HttpDigestAuthProvider.java +++ b/security/providers/http-auth/src/main/java/io/helidon/security/providers/httpauth/HttpDigestAuthProvider.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018, 2020 Oracle and/or its affiliates. + * Copyright (c) 2018, 2021 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,6 +39,7 @@ import io.helidon.security.ProviderRequest; import io.helidon.security.Role; import io.helidon.security.SecurityEnvironment; +import io.helidon.security.SecurityResponse; import io.helidon.security.Subject; import io.helidon.security.SubjectType; import io.helidon.security.spi.AuthenticationProvider; @@ -59,6 +60,7 @@ public final class HttpDigestAuthProvider extends SynchronousProvider implements private final List digestQopOptions = new LinkedList<>(); private final SecureUserStore userStore; + private final boolean optional; private final String realm; private final SubjectType subjectType; private final HttpDigest.Algorithm digestAlgorithm; @@ -70,6 +72,7 @@ public final class HttpDigestAuthProvider extends SynchronousProvider implements private HttpDigestAuthProvider(Builder builder) { this.userStore = builder.userStore; + this.optional = builder.optional; this.realm = builder.realm; this.subjectType = builder.subjectType; this.digestAlgorithm = builder.digestAlgorithm; @@ -132,14 +135,15 @@ protected AuthenticationResponse syncAuthenticate(ProviderRequest providerReques List authorizationHeader = headers.get(HEADER_AUTHENTICATION); if (null == authorizationHeader) { - return fail("No " + HEADER_AUTHENTICATION + " header"); + return failOrAbstain("No " + HEADER_AUTHENTICATION + " header"); } return authorizationHeader.stream() .filter(header -> header.toLowerCase().startsWith(DIGEST_PREFIX)) .findFirst() .map(value -> validateDigestAuth(value, providerRequest.env())) - .orElseGet(() -> fail("Authorization header does not contain digest authentication: " + authorizationHeader)); + .orElseGet(() -> + failOrAbstain("Authorization header does not contain digest authentication: " + authorizationHeader)); } @@ -150,7 +154,7 @@ private AuthenticationResponse validateDigestAuth(String headerValue, SecurityEn env.method().toLowerCase()); } catch (HttpAuthException e) { LOGGER.log(Level.FINEST, "Failed to process digest token", e); - return fail(e.getMessage()); + return failOrAbstain(e.getMessage()); } // decrypt byte[] bytes; @@ -159,10 +163,10 @@ private AuthenticationResponse validateDigestAuth(String headerValue, SecurityEn } catch (IllegalArgumentException e) { LOGGER.log(Level.FINEST, "Failed to base64 decode nonce", e); // not base 64 - return fail("Nonce must be base64 encoded"); + return failOrAbstain("Nonce must be base64 encoded"); } if (bytes.length < 17) { - return fail("Invalid nonce length"); + return failOrAbstain("Invalid nonce length"); } byte[] salt = new byte[SALT_LENGTH]; byte[] aesNonce = new byte[AES_NONCE_LENGTH]; @@ -178,16 +182,16 @@ private AuthenticationResponse validateDigestAuth(String headerValue, SecurityEn long nonceTimestamp = HttpAuthUtil.toLong(timestampBytes, 0, timestampBytes.length); //validate nonce if ((System.currentTimeMillis() - nonceTimestamp) > digestNonceTimeoutMillis) { - return fail("Nonce timeout"); + return failOrAbstain("Nonce timeout"); } } catch (Exception e) { LOGGER.log(Level.FINEST, "Failed to validate nonce", e); - return fail("Invalid nonce value"); + return failOrAbstain("Invalid nonce value"); } // validate realm if (!realm.equals(token.getRealm())) { - return fail("Invalid realm"); + return failOrAbstain("Invalid realm"); } return userStore.user(token.getUsername()) @@ -200,19 +204,26 @@ private AuthenticationResponse validateDigestAuth(String headerValue, SecurityEn return AuthenticationResponse.successService(buildSubject(user)); } } else { - return fail("Invalid username or password"); + return failOrAbstain("Invalid username or password"); } }) - .orElse(fail("Invalid username or password")); + .orElse(failOrAbstain("Invalid username or password")); } - private AuthenticationResponse fail(String message) { - return AuthenticationResponse.builder() - .statusCode(UNAUTHORIZED_STATUS_CODE) - .responseHeader(HEADER_AUTHENTICATION_REQUIRED, buildChallenge()) - .status(AuthenticationResponse.SecurityStatus.FAILURE) - .description(message) - .build(); + private AuthenticationResponse failOrAbstain(String message) { + if (optional) { + return AuthenticationResponse.builder() + .status(SecurityResponse.SecurityStatus.ABSTAIN) + .description(message) + .build(); + } else { + return AuthenticationResponse.builder() + .statusCode(UNAUTHORIZED_STATUS_CODE) + .responseHeader(HEADER_AUTHENTICATION_REQUIRED, buildChallenge()) + .status(AuthenticationResponse.SecurityStatus.FAILURE) + .description(message) + .build(); + } } private String buildChallenge() { @@ -266,6 +277,7 @@ public static final class Builder implements io.helidon.common.Builder digestQopOptions = new LinkedList<>(); private SecureUserStore userStore = EMPTY_STORE; + private boolean optional = false; private String realm = "Helidon"; private SubjectType subjectType = SubjectType.USER; private HttpDigest.Algorithm digestAlgorithm = HttpDigest.Algorithm.MD5; @@ -282,6 +294,7 @@ private Builder() { * @return updated builder instance */ public Builder config(Config config) { + config.get("optional").asBoolean().ifPresent(this::optional); config.get("realm").asString().ifPresent(this::realm); config.get("users").as(ConfigUserStore::create).ifPresent(this::userStore); config.get("algorithm").asString().as(HttpDigest.Algorithm::valueOf).ifPresent(this::digestAlgorithm); @@ -354,6 +367,19 @@ public Builder userStore(SecureUserStore store) { return this; } + /** + * Whether authentication is required. + * By default, request will fail if the authentication cannot be verified. + * If set to false, request will process and this provider will abstain. + * + * @param optional whether authentication is optional (true) or required (false) + * @return updated builder instance + */ + public Builder optional(boolean optional) { + this.optional = optional; + return this; + } + /** * Set the realm to use when challenging users. * diff --git a/security/providers/http-auth/src/test/java/io/helidon/security/providers/httpauth/HttpAuthProviderBuilderTest.java b/security/providers/http-auth/src/test/java/io/helidon/security/providers/httpauth/HttpAuthProviderBuilderTest.java index ecaa19b469b..ecc11442cf9 100644 --- a/security/providers/http-auth/src/test/java/io/helidon/security/providers/httpauth/HttpAuthProviderBuilderTest.java +++ b/security/providers/http-auth/src/test/java/io/helidon/security/providers/httpauth/HttpAuthProviderBuilderTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018, 2019 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2018, 2021 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -62,9 +62,11 @@ public static void initClass() { SecureUserStore us = userStore(); security = Security.builder() - .addAuthenticationProvider(basicAuthProvider(us), "basic") - .addAuthenticationProvider(digestAuthProvider(false, us), "digest") - .addAuthenticationProvider(digestAuthProvider(true, us), "digest_old") + .addAuthenticationProvider(basicAuthProvider(false,us), "basic") + .addAuthenticationProvider(basicAuthProvider(true, us), "basic_optional") + .addAuthenticationProvider(digestAuthProvider(false, false, us), "digest") + .addAuthenticationProvider(digestAuthProvider(false, true, us), "digest_optional") + .addAuthenticationProvider(digestAuthProvider(true, false, us), "digest_old") .build(); } @@ -85,15 +87,17 @@ private static SecureUserStore userStore() { }; } - private static Builder basicAuthProvider(SecureUserStore us) { + private static Builder basicAuthProvider(boolean optional, SecureUserStore us) { return HttpBasicAuthProvider.builder() .realm("mic") + .optional(optional) .userStore(us); } - private static Builder digestAuthProvider(boolean old, SecureUserStore us) { + private static Builder digestAuthProvider(boolean old, boolean optional, SecureUserStore us) { HttpDigestAuthProvider.Builder builder = HttpDigestAuthProvider.builder() .realm("mic") + .optional(optional) .digestServerSecret("pwd".toCharArray()) .userStore(us); @@ -110,6 +114,25 @@ public void init() { .build(); } + @Test + public void basicTestOptional() { + AuthenticationResponse response = context.atnClientBuilder().explicitProvider("basic_optional").buildAndGet(); + + assertThat(response.status().isSuccess(), is(false)); + assertThat(response.status().name(), is(SecurityResponse.SecurityStatus.ABSTAIN.name())); + assertThat(response.statusCode().orElse(200), is(200)); + assertThat(response.description().orElse(""), is("No authorization header")); + + setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, buildBasic("jack", "invalid_passworrd")); + System.out.println("test"); + response = context.atnClientBuilder().explicitProvider("basic_optional").buildAndGet(); + + assertThat(response.status().isSuccess(), is(false)); + assertThat(response.status().name(), is(SecurityResponse.SecurityStatus.ABSTAIN.name())); + assertThat(response.statusCode().orElse(200), is(200)); + assertThat(response.description().orElse(""), is("Invalid username or password")); + } + @Test public void basicTestFail() { AuthenticationResponse response = context.atnClientBuilder().buildAndGet(); @@ -215,6 +238,28 @@ public void basicTestJill() { assertThat(context.isUserInRole("user"), is(true)); } + @Test + public void digestTestOptional() { + AuthenticationResponse response = context.atnClientBuilder() + .explicitProvider("digest_optional") + .buildAndGet(); + + assertThat(response.status().isSuccess(), is(false)); + assertThat(response.status().name(), is(SecurityResponse.SecurityStatus.ABSTAIN.name())); + assertThat(response.statusCode().orElse(200), is(200)); + assertThat(response.description().orElse(""), is("No authorization header")); + + setHeader(context, HttpBasicAuthProvider.HEADER_AUTHENTICATION, buildDigest(HttpDigest.Qop.AUTH, "wrong", "user")); + response = context.atnClientBuilder() + .explicitProvider("digest_optional") + .buildAndGet(); + + assertThat(response.status().isSuccess(), is(false)); + assertThat(response.status().name(), is(SecurityResponse.SecurityStatus.ABSTAIN.name())); + assertThat(response.statusCode().orElse(200), is(200)); + assertThat(response.description().orElse(""), is("Invalid username or password")); + } + @Test public void digestTest401() { AuthenticationResponse response = context.atnClientBuilder() diff --git a/security/providers/jwt/src/main/java/io/helidon/security/providers/jwt/JwtProvider.java b/security/providers/jwt/src/main/java/io/helidon/security/providers/jwt/JwtProvider.java index efb6052612c..790c7e53d6e 100644 --- a/security/providers/jwt/src/main/java/io/helidon/security/providers/jwt/JwtProvider.java +++ b/security/providers/jwt/src/main/java/io/helidon/security/providers/jwt/JwtProvider.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018, 2020 Oracle and/or its affiliates. + * Copyright (c) 2018, 2021 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -149,23 +149,12 @@ protected AuthenticationResponse syncAuthenticate(ProviderRequest providerReques try { maybeToken = atnTokenHandler.extractToken(providerRequest.env().headers()); } catch (Exception e) { - if (optional) { - // maybe the token is for somebody else - return AuthenticationResponse.abstain(); - } else { - return AuthenticationResponse.failed("JWT header not available or in a wrong format", e); - } + return failOrAbstain("JWT header not available or in a wrong format" + e); } return maybeToken .map(this::authenticateToken) - .orElseGet(() -> { - if (optional) { - return AuthenticationResponse.abstain(); - } else { - return AuthenticationResponse.failed("JWT header not available or in a wrong format"); - } - }); + .orElseGet(() -> failOrAbstain("JWT header not available or in a wrong format")); } private AuthenticationResponse authenticateToken(String token) { @@ -174,7 +163,7 @@ private AuthenticationResponse authenticateToken(String token) { signedJwt = SignedJwt.parseToken(token); } catch (Exception e) { //invalid token - return AuthenticationResponse.failed("Invalid token", e); + return failOrAbstain("Invalid token" + e); } if (verifySignature) { Errors errors = signedJwt.verifySignature(verifyKeys, defaultJwk); @@ -185,16 +174,30 @@ private AuthenticationResponse authenticateToken(String token) { if (validate.isValid()) { return AuthenticationResponse.success(buildSubject(jwt, signedJwt)); } else { - return AuthenticationResponse.failed("Audience is invalid or missing: " + expectedAudience); + return failOrAbstain("Audience is invalid or missing: " + expectedAudience); } } else { - return AuthenticationResponse.failed(errors.toString()); + return failOrAbstain(errors.toString()); } } else { return AuthenticationResponse.success(buildSubject(signedJwt.getJwt(), signedJwt)); } } + private AuthenticationResponse failOrAbstain(String message) { + if (optional) { + return AuthenticationResponse.builder() + .status(SecurityResponse.SecurityStatus.ABSTAIN) + .description(message) + .build(); + } else { + return AuthenticationResponse.builder() + .status(AuthenticationResponse.SecurityStatus.FAILURE) + .description(message) + .build(); + } + } + Subject buildSubject(Jwt jwt, SignedJwt signedJwt) { Principal principal = buildPrincipal(jwt); diff --git a/security/providers/oidc/src/main/java/io/helidon/security/providers/oidc/OidcProvider.java b/security/providers/oidc/src/main/java/io/helidon/security/providers/oidc/OidcProvider.java index 2a3705bbc12..d85b1bd75e3 100644 --- a/security/providers/oidc/src/main/java/io/helidon/security/providers/oidc/OidcProvider.java +++ b/security/providers/oidc/src/main/java/io/helidon/security/providers/oidc/OidcProvider.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018, 2020 Oracle and/or its affiliates. + * Copyright (c) 2018, 2021 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -91,6 +91,7 @@ public final class OidcProvider extends SynchronousProvider implements AuthenticationProvider, OutboundSecurityProvider { private static final Logger LOGGER = Logger.getLogger(OidcProvider.class.getName()); + private final boolean optional; private final OidcConfig oidcConfig; private final TokenHandler paramHeaderHandler; @@ -102,6 +103,7 @@ public final class OidcProvider extends SynchronousProvider implements Authentic private final BiConsumer scopeAppender; private OidcProvider(Builder builder, OidcOutboundConfig oidcOutboundConfig) { + this.optional = builder.optional; this.oidcConfig = builder.oidcConfig; this.propagate = builder.propagate && (oidcOutboundConfig.hasOutbound()); this.useJwtGroups = builder.useJwtGroups; @@ -249,7 +251,7 @@ protected AuthenticationResponse syncAuthenticate(ProviderRequest providerReques } } } catch (SecurityException e) { - return AuthenticationResponse.failed("Failed to extract one of the configured tokens", e); + return failOrAbstain("Failed to extract one of the configured tokens" + e); } if (token.isPresent()) { @@ -364,7 +366,27 @@ private AuthenticationResponse errorResponse(ProviderRequest providerRequest, } } + private AuthenticationResponse failOrAbstain(String message) { + if (optional) { + return AuthenticationResponse.builder() + .status(SecurityResponse.SecurityStatus.ABSTAIN) + .description(message) + .build(); + } else { + return AuthenticationResponse.builder() + .status(AuthenticationResponse.SecurityStatus.FAILURE) + .description(message) + .build(); + } + } + private AuthenticationResponse errorResponseNoRedirect(String code, String description, Http.Status status) { + if (optional) { + return AuthenticationResponse.builder() + .status(SecurityResponse.SecurityStatus.ABSTAIN) + .description(description) + .build(); + } if (null == code) { return AuthenticationResponse.builder() .status(SecurityResponse.SecurityStatus.FAILURE) @@ -569,6 +591,7 @@ private Principal buildPrincipal(Jwt jwt) { * Builder for {@link io.helidon.security.providers.oidc.OidcProvider}. */ public static final class Builder implements io.helidon.common.Builder { + private boolean optional = false; private OidcConfig oidcConfig; // identity propagation is disabled by default. In general we should not reuse the same token // for outbound calls, unless it is the same audience @@ -633,6 +656,7 @@ public OidcProvider build() { * @return updated builder instance */ public Builder config(Config config) { + config.get("optional").asBoolean().ifPresent(this::optional); if (null == oidcConfig) { if (config.get("identity-uri").exists()) { oidcConfig = OidcConfig.create(config); @@ -683,6 +707,19 @@ public Builder oidcConfig(OidcConfig config) { return this; } + /** + * Whether authentication is required. + * By default, request will fail if the authentication cannot be verified. + * If set to false, request will process and this provider will abstain. + * + * @param optional whether authentication is optional (true) or required (false) + * @return updated builder instance + */ + public Builder optional(boolean optional) { + this.optional = optional; + return this; + } + /** * Claim {@code groups} from JWT will be used to automatically add * groups to current subject (may be used with {@link javax.annotation.security.RolesAllowed} annotation).