From 0561d73ae2af5690748b48d0c801c83146fe0fea Mon Sep 17 00:00:00 2001 From: Pedro Igor Date: Fri, 24 Aug 2018 13:38:46 -0300 Subject: [PATCH] [KEYCLOAK-6285] - HTTP Challenge Authentication Flow --- .../utils/DefaultAuthenticationFlows.java | 45 +++++ .../challenge/BasicAuthAuthenticator.java | 158 ++++++++++++++++++ .../BasicAuthAuthenticatorFactory.java | 103 ++++++++++++ .../challenge/BasicAuthOTPAuthenticator.java | 67 ++++++++ .../BasicAuthOTPAuthenticatorFactory.java | 104 ++++++++++++ .../NoCookieFlowRedirectAuthenticator.java | 77 +++++++++ ...ookieFlowRedirectAuthenticatorFactory.java | 103 ++++++++++++ .../ClientIdAndSecretAuthenticator.java | 12 +- ...ycloak.authentication.AuthenticatorFactory | 3 + .../authentication/InitialFlowsTest.java | 13 ++ .../admin/authentication/ProvidersTest.java | 3 + .../kerberos/AbstractKerberosTest.java | 9 +- .../federation/kerberos/KerberosLdapTest.java | 36 ++++ .../testsuite/forms/FlowOverrideTest.java | 85 +++++++++- .../resources/kerberos/kerberosrealm.json | 11 ++ 15 files changed, 823 insertions(+), 6 deletions(-) create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthAuthenticator.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthAuthenticatorFactory.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthOTPAuthenticator.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthOTPAuthenticatorFactory.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/challenge/NoCookieFlowRedirectAuthenticator.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/challenge/NoCookieFlowRedirectAuthenticatorFactory.java diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java b/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java index 43b40e4a7e..fca1a71979 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/DefaultAuthenticationFlows.java @@ -43,6 +43,7 @@ public class DefaultAuthenticationFlows { public static final String LOGIN_FORMS_FLOW = "forms"; public static final String SAML_ECP_FLOW = "saml ecp"; public static final String DOCKER_AUTH = "docker auth"; + public static final String HTTP_CHALLENGE_FLOW = "http challenge"; public static final String CLIENT_AUTHENTICATION_FLOW = "clients"; public static final String FIRST_BROKER_LOGIN_FLOW = "first broker login"; @@ -60,6 +61,7 @@ public class DefaultAuthenticationFlows { if (realm.getFlowByAlias(FIRST_BROKER_LOGIN_FLOW) == null) firstBrokerLoginFlow(realm, false); if (realm.getFlowByAlias(SAML_ECP_FLOW) == null) samlEcpProfile(realm); if (realm.getFlowByAlias(DOCKER_AUTH) == null) dockerAuthenticationFlow(realm); + if (realm.getFlowByAlias(HTTP_CHALLENGE_FLOW) == null) httpChallengeFlow(realm); } public static void migrateFlows(RealmModel realm) { if (realm.getFlowByAlias(BROWSER_FLOW) == null) browserFlow(realm, true); @@ -70,6 +72,7 @@ public class DefaultAuthenticationFlows { if (realm.getFlowByAlias(FIRST_BROKER_LOGIN_FLOW) == null) firstBrokerLoginFlow(realm, true); if (realm.getFlowByAlias(SAML_ECP_FLOW) == null) samlEcpProfile(realm); if (realm.getFlowByAlias(DOCKER_AUTH) == null) dockerAuthenticationFlow(realm); + if (realm.getFlowByAlias(HTTP_CHALLENGE_FLOW) == null) httpChallengeFlow(realm); } public static void registrationFlow(RealmModel realm) { @@ -570,4 +573,46 @@ public class DefaultAuthenticationFlows { realm.addAuthenticatorExecution(execution); } + + public static void httpChallengeFlow(RealmModel realm) { + AuthenticationFlowModel challengeFlow = new AuthenticationFlowModel(); + challengeFlow.setAlias(HTTP_CHALLENGE_FLOW); + challengeFlow.setDescription("An authentication flow based on challenge-response HTTP Authentication Schemes"); + challengeFlow.setProviderId("basic-flow"); + challengeFlow.setTopLevel(true); + challengeFlow.setBuiltIn(true); + challengeFlow = realm.addAuthenticationFlow(challengeFlow); + + AuthenticationExecutionModel execution = new AuthenticationExecutionModel(); + execution.setParentFlow(challengeFlow.getId()); + execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED); + execution.setAuthenticator("no-cookie-redirect"); + execution.setPriority(10); + execution.setAuthenticatorFlow(false); + realm.addAuthenticatorExecution(execution); + + execution = new AuthenticationExecutionModel(); + execution.setParentFlow(challengeFlow.getId()); + execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED); + execution.setAuthenticator("basic-auth"); + execution.setPriority(20); + execution.setAuthenticatorFlow(false); + realm.addAuthenticatorExecution(execution); + + execution = new AuthenticationExecutionModel(); + execution.setParentFlow(challengeFlow.getId()); + execution.setRequirement(AuthenticationExecutionModel.Requirement.DISABLED); + execution.setAuthenticator("basic-auth-otp"); + execution.setPriority(30); + execution.setAuthenticatorFlow(false); + realm.addAuthenticatorExecution(execution); + + execution = new AuthenticationExecutionModel(); + execution.setParentFlow(challengeFlow.getId()); + execution.setRequirement(AuthenticationExecutionModel.Requirement.DISABLED); + execution.setAuthenticator("auth-spnego"); + execution.setPriority(40); + execution.setAuthenticatorFlow(false); + realm.addAuthenticatorExecution(execution); + } } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthAuthenticator.java new file mode 100644 index 0000000000..74e8760e54 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthAuthenticator.java @@ -0,0 +1,158 @@ +/* + * Copyright 2018 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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 org.keycloak.authentication.authenticators.challenge; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.util.BasicAuthHelper; + +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class BasicAuthAuthenticator extends AbstractUsernameFormAuthenticator implements Authenticator { + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public void authenticate(AuthenticationFlowContext context) { + String authorizationHeader = getAuthorizationHeader(context); + + if (authorizationHeader == null) { + context.challenge(challengeResponse(context)); + return; + } + + String[] challenge = getChallenge(authorizationHeader); + + if (challenge == null) { + context.challenge(challengeResponse(context)); + return; + } + + if (onAuthenticate(context, challenge)) { + context.success(); + return; + } + + context.setUser(null); + context.challenge(challengeResponse(context)); + } + + protected boolean onAuthenticate(AuthenticationFlowContext context, String[] challenge) { + if (checkUsernameAndPassword(context, challenge[0], challenge[1])) { + return true; + } + + return false; + } + + protected String getAuthorizationHeader(AuthenticationFlowContext context) { + return context.getHttpRequest().getHttpHeaders().getRequestHeaders().getFirst(HttpHeaders.AUTHORIZATION); + } + + protected boolean checkUsernameAndPassword(AuthenticationFlowContext context, String username, String password) { + MultivaluedMap map = new MultivaluedHashMap<>(); + + map.putSingle(AuthenticationManager.FORM_USERNAME, username); + map.putSingle(CredentialRepresentation.PASSWORD, password); + + if (validateUserAndPassword(context, map)) { + return true; + } + + return false; + } + + protected String[] getChallenge(String authorizationHeader) { + String[] challenge = BasicAuthHelper.parseHeader(authorizationHeader); + + if (challenge.length < 2) { + return null; + } + + return challenge; + } + + @Override + protected Response invalidUser(AuthenticationFlowContext context) { + return challengeResponse(context); + } + + @Override + protected Response disabledUser(AuthenticationFlowContext context) { + return challengeResponse(context); + } + + @Override + protected Response temporarilyDisabledUser(AuthenticationFlowContext context) { + return challengeResponse(context); + } + + @Override + protected Response invalidCredentials(AuthenticationFlowContext context) { + return challengeResponse(context); + } + + @Override + protected Response setDuplicateUserChallenge(AuthenticationFlowContext context, String eventError, String loginFormError, AuthenticationFlowError authenticatorError) { + return challengeResponse(context); + } + + @Override + public void action(AuthenticationFlowContext context) { + + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } + + @Override + public void close() { + + } + + private Response challengeResponse(AuthenticationFlowContext context) { + return Response.status(401).header(HttpHeaders.WWW_AUTHENTICATE, getHeader(context)).build(); + } + + private String getHeader(AuthenticationFlowContext context) { + return "Basic realm=\"" + context.getRealm().getName() + "\""; + } +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthAuthenticatorFactory.java new file mode 100644 index 0000000000..b4540b7939 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthAuthenticatorFactory.java @@ -0,0 +1,103 @@ +/* + * Copyright 2018 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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 org.keycloak.authentication.authenticators.challenge; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.Collections; +import java.util.List; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class BasicAuthAuthenticatorFactory implements AuthenticatorFactory { + + public static final String PROVIDER_ID = "basic-auth"; + public static final BasicAuthAuthenticator SINGLETON = new BasicAuthAuthenticator(); + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getReferenceCategory() { + return UserCredentialModel.PASSWORD; + } + + @Override + public boolean isConfigurable() { + return false; + } + public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED, AuthenticationExecutionModel.Requirement.OPTIONAL, AuthenticationExecutionModel.Requirement.DISABLED + }; + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public String getDisplayType() { + return "Basic Auth Challenge"; + } + + @Override + public String getHelpText() { + return "Challenge-response authentication using HTTP BASIC scheme."; + } + + @Override + public List getConfigProperties() { + return Collections.EMPTY_LIST; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthOTPAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthOTPAuthenticator.java new file mode 100644 index 0000000000..99f8eb57ba --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthOTPAuthenticator.java @@ -0,0 +1,67 @@ +/* + * Copyright 2018 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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 org.keycloak.authentication.authenticators.challenge; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OTPPolicy; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class BasicAuthOTPAuthenticator extends BasicAuthAuthenticator implements Authenticator { + + @Override + protected boolean onAuthenticate(AuthenticationFlowContext context, String[] challenge) { + String username = challenge[0]; + String password = challenge[1]; + OTPPolicy otpPolicy = context.getRealm().getOTPPolicy(); + int otpLength = otpPolicy.getDigits(); + + if (password.length() < otpLength) { + return false; + } + + password = password.substring(0, password.length() - otpLength); + + if (checkUsernameAndPassword(context, username, password)) { + String otp = password.substring(password.length() - otpLength); + + if (checkOtp(context, otp)) { + return true; + } + } + + return false; + } + + private boolean checkOtp(AuthenticationFlowContext context, String otp) { + return context.getSession().userCredentialManager().isValid(context.getRealm(), context.getUser(), + UserCredentialModel.otp(context.getRealm().getOTPPolicy().getType(), otp)); + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return session.userCredentialManager().isConfiguredFor(realm, user, realm.getOTPPolicy().getType()); + } +} + diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthOTPAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthOTPAuthenticatorFactory.java new file mode 100644 index 0000000000..580e1e21b6 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/challenge/BasicAuthOTPAuthenticatorFactory.java @@ -0,0 +1,104 @@ +/* + * Copyright 2018 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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 org.keycloak.authentication.authenticators.challenge; + +import java.util.Collections; +import java.util.List; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.provider.ProviderConfigProperty; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class BasicAuthOTPAuthenticatorFactory implements AuthenticatorFactory { + + public static final String PROVIDER_ID = "basic-auth-otp"; + public static final BasicAuthOTPAuthenticator SINGLETON = new BasicAuthOTPAuthenticator(); + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getReferenceCategory() { + return UserCredentialModel.PASSWORD; + } + + @Override + public boolean isConfigurable() { + return false; + } + public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED, AuthenticationExecutionModel.Requirement.OPTIONAL, AuthenticationExecutionModel.Requirement.DISABLED + }; + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public String getDisplayType() { + return "Basic Auth Password+OTP"; + } + + @Override + public String getHelpText() { + return "Challenge-response authentication using HTTP BASIC scheme. Password param should contain a combination of password + otp. Realm's OTP policy is used to determine how to parse this. This SHOULD NOT BE USED in conjection with regular basic auth provider."; + } + + @Override + public List getConfigProperties() { + return Collections.EMPTY_LIST; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + +} + diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/challenge/NoCookieFlowRedirectAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/challenge/NoCookieFlowRedirectAuthenticator.java new file mode 100644 index 0000000000..3ee6eed245 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/challenge/NoCookieFlowRedirectAuthenticator.java @@ -0,0 +1,77 @@ +/* + * Copyright 2018 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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 org.keycloak.authentication.authenticators.challenge; + +import javax.ws.rs.HttpMethod; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response; + +import org.jboss.resteasy.spi.HttpRequest; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.resources.LoginActionsService; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class NoCookieFlowRedirectAuthenticator implements Authenticator { + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public void authenticate(AuthenticationFlowContext context) { + HttpRequest httpRequest = context.getHttpRequest(); + + // only do redirects for GET requests + if (HttpMethod.GET.equalsIgnoreCase(httpRequest.getHttpMethod())) { + if (!httpRequest.getUri().getQueryParameters().containsKey(LoginActionsService.AUTH_SESSION_ID)) { + Response response = Response.status(302).header(HttpHeaders.LOCATION, context.getRefreshUrl(true)).build(); + context.challenge(response); + return; + } + } + + context.success(); + } + + @Override + public void action(AuthenticationFlowContext context) { + + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } + + @Override + public void close() { + + } +} + diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/challenge/NoCookieFlowRedirectAuthenticatorFactory.java b/services/src/main/java/org/keycloak/authentication/authenticators/challenge/NoCookieFlowRedirectAuthenticatorFactory.java new file mode 100644 index 0000000000..452df58bdc --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/challenge/NoCookieFlowRedirectAuthenticatorFactory.java @@ -0,0 +1,103 @@ +/* + * Copyright 2018 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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 org.keycloak.authentication.authenticators.challenge; + +import java.util.Collections; +import java.util.List; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.provider.ProviderConfigProperty; + +/** + * @author Bill Burke + * @version $Revision: 1 $ + */ +public class NoCookieFlowRedirectAuthenticatorFactory implements AuthenticatorFactory { + + public static final String PROVIDER_ID = "no-cookie-redirect"; + public static final NoCookieFlowRedirectAuthenticator SINGLETON = new NoCookieFlowRedirectAuthenticator(); + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getReferenceCategory() { + return UserCredentialModel.PASSWORD; + } + + @Override + public boolean isConfigurable() { + return false; + } + public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED + }; + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public String getDisplayType() { + return "Browser Redirect/Refresh"; + } + + @Override + public String getHelpText() { + return "Perform a 302 redirect to get user agent's current URI on authenticate path with an auth_session_id query parameter. This is for client's that do not support cookies."; + } + + @Override + public List getConfigProperties() { + return Collections.EMPTY_LIST; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java index 6e051df558..f268f3673f 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/client/ClientIdAndSecretAuthenticator.java @@ -83,9 +83,15 @@ public class ClientIdAndSecretAuthenticator extends AbstractClientAuthenticator } } - if (formData != null && client_id == null) { - client_id = formData.getFirst(OAuth2Constants.CLIENT_ID); - clientSecret = formData.getFirst(OAuth2Constants.CLIENT_SECRET); + if (formData != null) { + // even if basic challenge response exist, we check if client id was explicitly set in the request as a form param, + // so we can also support clients overriding flows and using challenges (e.g: basic) to authenticate their users + if (formData.containsKey(OAuth2Constants.CLIENT_ID)) { + client_id = formData.getFirst(OAuth2Constants.CLIENT_ID); + } + if (formData.containsKey(OAuth2Constants.CLIENT_SECRET)) { + clientSecret = formData.getFirst(OAuth2Constants.CLIENT_SECRET); + } } if (client_id == null) { diff --git a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory index ee29448342..4ecc8ff1a1 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -39,3 +39,6 @@ org.keycloak.authentication.authenticators.x509.X509ClientCertificateAuthenticat org.keycloak.authentication.authenticators.x509.ValidateX509CertificateUsernameFactory org.keycloak.protocol.docker.DockerAuthenticatorFactory org.keycloak.authentication.authenticators.console.ConsoleUsernamePasswordAuthenticatorFactory +org.keycloak.authentication.authenticators.challenge.BasicAuthAuthenticatorFactory +org.keycloak.authentication.authenticators.challenge.BasicAuthOTPAuthenticatorFactory +org.keycloak.authentication.authenticators.challenge.NoCookieFlowRedirectAuthenticatorFactory \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/InitialFlowsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/InitialFlowsTest.java index d6742cf611..6ac78f8776 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/InitialFlowsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/InitialFlowsTest.java @@ -185,6 +185,19 @@ public class InitialFlowsTest extends AbstractAuthenticationTest { addExecInfo(execs, "OTP Form", "auth-otp-form", false, 2, 1, OPTIONAL, null, new String[]{REQUIRED, OPTIONAL, DISABLED}); expected.add(new FlowExecutions(flow, execs)); + flow = newFlow("http challenge", "An authentication flow based on challenge-response HTTP Authentication Schemes","basic-flow", true, true); + addExecExport(flow, null, false, "no-cookie-redirect", false, null, REQUIRED, 10); + addExecExport(flow, null, false, "basic-auth", false, null, REQUIRED, 20); + addExecExport(flow, null, false, "basic-auth-otp", false, null, DISABLED, 30); + addExecExport(flow, null, false, "auth-spnego", false, null, DISABLED, 40); + + execs = new LinkedList<>(); + addExecInfo(execs, "Browser Redirect/Refresh", "no-cookie-redirect", false, 0, 0, REQUIRED, null, new String[]{REQUIRED}); + addExecInfo(execs, "Basic Auth Challenge", "basic-auth", false, 0, 1, REQUIRED, null, new String[]{REQUIRED, OPTIONAL, DISABLED}); + addExecInfo(execs, "Basic Auth Password+OTP", "basic-auth-otp", false, 0, 2, DISABLED, null, new String[]{REQUIRED, OPTIONAL, DISABLED}); + addExecInfo(execs, "Kerberos", "auth-spnego", false, 0, 3, DISABLED, null, new String[]{ALTERNATIVE, REQUIRED, DISABLED}); + expected.add(new FlowExecutions(flow, execs)); + flow = newFlow("registration", "registration flow", "basic-flow", true, true); addExecExport(flow, "registration form", false, "registration-page-form", true, null, REQUIRED, 10); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java index 794199b278..b078e15d0c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/ProvidersTest.java @@ -150,6 +150,8 @@ public class ProvidersTest extends AbstractAuthenticationTest { "Validates a username and password from login form."); addProviderInfo(result, "auth-x509-client-username-form", "X509/Validate Username Form", "Validates username and password from X509 client certificate received as a part of mutual SSL handshake."); + addProviderInfo(result, "basic-auth", "Basic Auth Challenge", "Challenge-response authentication using HTTP BASIC scheme."); + addProviderInfo(result, "basic-auth-otp", "Basic Auth Password+OTP", "Challenge-response authentication using HTTP BASIC scheme. Password param should contain a combination of password + otp. Realm's OTP policy is used to determine how to parse this. This SHOULD NOT BE USED in conjection with regular basic auth provider."); addProviderInfo(result, "console-username-password", "Username Password Challenge", "Proprietary challenge protocol for CLI clients that queries for username password"); addProviderInfo(result, "direct-grant-auth-x509-username", "X509/Validate Username", @@ -174,6 +176,7 @@ public class ProvidersTest extends AbstractAuthenticationTest { "User reviews and updates profile data retrieved from Identity Provider in the displayed form"); addProviderInfo(result, "idp-username-password-form", "Username Password Form for identity provider reauthentication", "Validates a password from login form. Username is already known from identity provider authentication"); + addProviderInfo(result, "no-cookie-redirect", "Browser Redirect/Refresh", "Perform a 302 redirect to get user agent's current URI on authenticate path with an auth_session_id query parameter. This is for client's that do not support cookies."); addProviderInfo(result, "push-button-authenticator", "TEST: Button Login", "Just press the button to login."); addProviderInfo(result, "reset-credential-email", "Send Reset Email", "Send email to user and wait for response."); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/kerberos/AbstractKerberosTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/kerberos/AbstractKerberosTest.java index 58094b566e..64f70dd25e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/kerberos/AbstractKerberosTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/kerberos/AbstractKerberosTest.java @@ -178,13 +178,18 @@ public abstract class AbstractKerberosTest extends AbstractAuthTest { protected AccessToken assertSuccessfulSpnegoLogin(String loginUsername, String expectedUsername, String password) throws Exception { + return assertSuccessfulSpnegoLogin("kerberos-app", loginUsername, expectedUsername, password); + } + + protected AccessToken assertSuccessfulSpnegoLogin(String clientId, String loginUsername, String expectedUsername, String password) throws Exception { + oauth.clientId(clientId); Response spnegoResponse = spnegoLogin(loginUsername, password); Assert.assertEquals(302, spnegoResponse.getStatus()); List users = testRealmResource().users().search(expectedUsername, 0, 1); String userId = users.get(0).getId(); events.expectLogin() - .client("kerberos-app") + .client(clientId) .user(userId) .detail(Details.USERNAME, expectedUsername) .assertEvent(); @@ -233,7 +238,7 @@ public abstract class AbstractKerberosTest extends AbstractAuthTest { if (response.getLocation() == null) return response; String uri = response.getLocation().toString(); - if (uri.contains("login-actions/required-action")) { + if (uri.contains("login-actions/required-action") || uri.contains("auth_session_id")) { response = client.target(uri).request().get(); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/kerberos/KerberosLdapTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/kerberos/KerberosLdapTest.java index 1488a4c823..7d032e7ecd 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/kerberos/KerberosLdapTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/kerberos/KerberosLdapTest.java @@ -17,7 +17,9 @@ package org.keycloak.testsuite.federation.kerberos; +import java.util.HashMap; import java.util.List; +import java.util.Map; import javax.ws.rs.core.Response; @@ -26,6 +28,10 @@ import org.junit.ClassRule; import org.junit.Test; import org.keycloak.events.Details; import org.keycloak.federation.kerberos.CommonKerberosConfig; +import org.keycloak.models.AuthenticationFlowBindings; +import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation; +import org.keycloak.representations.idm.AuthenticationFlowRepresentation; +import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ComponentRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.storage.UserStorageProvider; @@ -73,6 +79,36 @@ public class KerberosLdapTest extends AbstractKerberosSingleRealmTest { assertUser("hnelson", "hnelson@keycloak.org", "Horatio", "Nelson", false); } + @Test + public void testClientOverrideFlowUsingBrowserHttpChallenge() throws Exception { + List executions = testRealmResource().flows().getExecutions("http challenge"); + + for (AuthenticationExecutionInfoRepresentation execution : executions) { + if ("basic-auth".equals(execution.getProviderId())) { + execution.setRequirement("OPTIONAL"); + testRealmResource().flows().updateExecutions("http challenge", execution); + } + if ("auth-spnego".equals(execution.getProviderId())) { + execution.setRequirement("ALTERNATIVE"); + testRealmResource().flows().updateExecutions("http challenge", execution); + } + } + + + Map flows = new HashMap<>(); + AuthenticationFlowRepresentation flow = testRealmResource().flows().getFlows().stream().filter(flowRep -> flowRep.getAlias().equalsIgnoreCase("http challenge")).findAny().get(); + + flows.put(AuthenticationFlowBindings.BROWSER_BINDING, flow.getId()); + + ClientRepresentation client = testRealmResource().clients().findByClientId("kerberos-app-challenge").get(0); + + client.setAuthenticationFlowBindingOverrides(flows); + + testRealmResource().clients().get(client.getId()).update(client); + + assertSuccessfulSpnegoLogin(client.getClientId(),"hnelson", "hnelson", "secret"); + } + @Test public void validatePasswordPolicyTest() throws Exception{ updateProviderEditMode(UserStorageProvider.EditMode.WRITABLE); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/FlowOverrideTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/FlowOverrideTest.java index 80bff7be02..036bdb316e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/FlowOverrideTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/FlowOverrideTest.java @@ -17,6 +17,7 @@ package org.keycloak.testsuite.forms; +import org.apache.http.client.utils.URLEncodedUtils; import org.jboss.arquillian.container.test.api.Deployment; import org.jboss.arquillian.graphene.page.Page; import org.jboss.shrinkwrap.api.spec.WebArchive; @@ -53,6 +54,7 @@ import javax.ws.rs.core.Form; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.Response; +import java.nio.charset.Charset; import java.util.List; import static org.junit.Assert.assertEquals; @@ -66,6 +68,8 @@ public class FlowOverrideTest extends AbstractTestRealmKeycloakTest { public static final String TEST_APP_DIRECT_OVERRIDE = "test-app-direct-override"; public static final String TEST_APP_FLOW = "test-app-flow"; + public static final String TEST_APP_HTTP_CHALLENGE = "http-challenge-client"; + @Rule public AssertEvents events = new AssertEvents(this); @@ -189,7 +193,16 @@ public class FlowOverrideTest extends AbstractTestRealmKeycloakTest { client.setAuthenticationFlowBindingOverride(AuthenticationFlowBindings.DIRECT_GRANT_BINDING, directGrant.getId()); - + client = realm.addClient(TEST_APP_HTTP_CHALLENGE); + client.setSecret("password"); + client.setBaseUrl("http://localhost:8180/auth/realms/master/app/auth"); + client.setManagementUrl("http://localhost:8180/auth/realms/master/app/admin"); + client.setEnabled(true); + client.addRedirectUri("http://localhost:8180/auth/realms/master/app/auth/*"); + client.setPublicClient(true); + client.setDirectAccessGrantsEnabled(true); + client.setAuthenticationFlowBindingOverride(AuthenticationFlowBindings.DIRECT_GRANT_BINDING, realm.getFlowByAlias("http challenge").getId()); + client.setAuthenticationFlowBindingOverride(AuthenticationFlowBindings.BROWSER_BINDING, realm.getFlowByAlias("http challenge").getId()); }); } @@ -323,6 +336,76 @@ public class FlowOverrideTest extends AbstractTestRealmKeycloakTest { events.clear(); } + @Test + public void testClientOverrideFlowUsingDirectGrantHttpChallenge() { + Client httpClient = javax.ws.rs.client.ClientBuilder.newClient(); + String grantUri = oauth.getResourceOwnerPasswordCredentialGrantUrl(); + WebTarget grantTarget = httpClient.target(grantUri); + + // no username/password + Form form = new Form(); + form.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD); + form.param(OAuth2Constants.CLIENT_ID, TEST_APP_HTTP_CHALLENGE); + Response response = grantTarget.request() + .post(Entity.form(form)); + assertEquals("Basic realm=\"test\"", response.getHeaderString(HttpHeaders.WWW_AUTHENTICATE)); + assertEquals(401, response.getStatus()); + response.close(); + + // now, username password using basic challenge response + response = grantTarget.request() + .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("test-user@localhost", "password")) + .post(Entity.form(form)); + assertEquals(200, response.getStatus()); + response.close(); + + httpClient.close(); + events.clear(); + } + + @Test + public void testClientOverrideFlowUsingBrowserHttpChallenge() { + Client httpClient = javax.ws.rs.client.ClientBuilder.newClient(); + oauth.clientId(TEST_APP_HTTP_CHALLENGE); + String grantUri = oauth.getLoginFormUrl(); + WebTarget grantTarget = httpClient.target(grantUri); + + Response response = grantTarget.request().get(); + assertEquals(302, response.getStatus()); + String location = response.getHeaderString(HttpHeaders.LOCATION); + response.close(); + + // first challenge + response = httpClient.target(location).request().get(); + assertEquals("Basic realm=\"test\"", response.getHeaderString(HttpHeaders.WWW_AUTHENTICATE)); + assertEquals(401, response.getStatus()); + response.close(); + + // now, username password using basic challenge response + response = httpClient.target(location).request() + .header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader("test-user@localhost", "password")) + .post(Entity.form(new Form())); + assertEquals(302, response.getStatus()); + location = response.getHeaderString(HttpHeaders.LOCATION); + response.close(); + + Form form = new Form(); + + form.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE); + form.param(OAuth2Constants.CLIENT_ID, TEST_APP_HTTP_CHALLENGE); + form.param(OAuth2Constants.REDIRECT_URI, "http://localhost:8180/auth/realms/master/app/auth"); + form.param(OAuth2Constants.CODE, location.substring(location.indexOf(OAuth2Constants.CODE) + OAuth2Constants.CODE.length() + 1)); + + // exchange code to token + response = httpClient.target(oauth.getAccessTokenUrl()).request() + .post(Entity.form(form)); + assertEquals(200, response.getStatus()); + response.close(); + + httpClient.close(); + events.clear(); + } + @Test public void testRestInterface() throws Exception { ClientsResource clients = adminClient.realm("test").clients(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/kerberos/kerberosrealm.json b/testsuite/integration-arquillian/tests/base/src/test/resources/kerberos/kerberosrealm.json index 2588e4aab3..ec41b8d52e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/kerberos/kerberosrealm.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/kerberos/kerberosrealm.json @@ -42,6 +42,17 @@ "/auth/realms/master/app/*" ], "secret": "password" + }, + { + "clientId": "kerberos-app-challenge", + "enabled": true, + "adminUrl": "/kerberos-portal/logout", + "baseUrl": "/kerberos-portal", + "redirectUris": [ + "/kerberos-portal/*", + "/auth/realms/master/app/*" + ], + "secret": "password" } ], "roles" : {