Skip to content

Commit

Permalink
Implement a conditional authenticator to check if a sub-flow was exec…
Browse files Browse the repository at this point in the history
…uted or not previously in the process (keycloak#35668)

Closes keycloak#35231

Signed-off-by: rmartinc <[email protected]>


Co-authored-by: Marek Posolda <[email protected]>
Co-authored-by: andymunro <[email protected]>
  • Loading branch information
3 people authored Dec 12, 2024
1 parent 8d934de commit bbca611
Show file tree
Hide file tree
Showing 12 changed files with 513 additions and 4 deletions.
4 changes: 4 additions & 0 deletions docs/documentation/release_notes/topics/26_1_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ by the LDAP provider.

As OpenShift v3 reached end-of-life a while back, support for identity brokering with OpenShift v3 has been removed from Keycloak.

= New conditional authenticator `Condition - sub-flow executed`

The `Condition - sub-flow executed` is a new conditional authenticator in {project_name}. The condition checks if a previous sub-flow was executed (or not executed) successfully during the authentication flow execution. For more details, see link:{adminguide_link}#conditions-in-conditional-flows[Conditions in conditional flows].

= Defining dependencies between provider factories

When developing extensions for {project_name}, developers can now specify dependencies between provider factories classes by implementing the method `dependsOn()` in the `ProviderFactory` interface.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ Negate output:::
You can negate the output.
In other words, the attribute should not be present.

`Condition - sub-flow executed`::
The condition checks if a previous sub-flow was successfully executed (or not executed) in the authentication process. Therefore, the flow can trigger other steps based on a previous sub-flow termination. These configuration fields exist:

Flow name:::
The sub-flow name to check if it was executed or not executed. Required.

Check result:::
When the condition evaluates to true. If `executed` returns true when the configured sub-flow was executed with output success, false otherwise. If `not-executed` returns false when the sub-flow was executed with output success, true otherwise (the negation of the previous option).


==== Explicitly deny/allow access in conditional flows

You can allow or deny access to resources in a conditional flow.
Expand Down Expand Up @@ -85,4 +95,40 @@ The last thing is defining the property with an error message in the login theme
[source]
----
deny-role1 = You do not have required role!
----
----

==== 2FA conditional workflow examples

The section presents some examples of conditional workflows that integrates 2nd Factor Authentication (2FA) in different ways. The examples copy the default `browser` flow and modify the configuration inside the `forms` sub-flow.

===== Conditional 2FA sub-flow

The default `browser` flow uses a `Conditional OTP` sub-flow that already gives a 2FA with OTP Form (One Time Password). Following the same idea, different 2FA methods can be integrated with the `Condition - User Configured`.

.2FA all alternative
image:images/2fa-example1.png[2FA all alternative]

The `forms` sub-flow contains another `2FA` conditional sub-flow with `Condition - user configured`. Three 2FA steps (OTP, Webauthn and Recovery Codes) are allowed as alternative steps. The user will be able to choose one of the three options, if they are configured for the user. As the sub-flow is conditional, the authentication process will complete successfully if no 2FA credential is configured.

===== Conditional 2FA sub-flow and deny access

The second example continues the previous one. After the `2FA` sub-flow, another flow `Deny access if no 2FA` is used to check if the previous `2FA` was not executed. In that case (the user has no 2FA credential configured) the access is denied.

.2FA all alternative and deny access
image:images/2fa-example2.png[2FA all alternative and deny access]

The `Condition - sub-flow executed` is configured to detect if the `2FA` sub-flow was not executed previously.

.Configuration for the sub-flow executed
image:images/2fa-example2-config.png[Configuration for the sub-flow executed]

The step `Deny access` denies the authentication if not executed.

===== Conditional 2FA sub-flow with OTP default

The last example is very similar to the previous one. Instead of denying the access, step `OTP Form` is configured as required.

.2FA all alternative with OTP default
image:images/2fa-example3.png[2FA all alternative with OTP default]

With this flow, if the user has none of the 2FA methods configured, the OTP setup will be enforced to continue the login.
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* Copyright 2024 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.conditional;

import java.util.stream.Stream;
import org.jboss.logging.Logger;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.sessions.AuthenticationSessionModel;

/**
* <p>Conditional authenticator to know if a sub-flow was executed successfully in the authentication flow.</p>
*
* @author rmartinc
*/
public class ConditionalSubFlowExecutedAuthenticator implements ConditionalAuthenticator {

protected static final ConditionalSubFlowExecutedAuthenticator SINGLETON = new ConditionalSubFlowExecutedAuthenticator();
private static final Logger logger = Logger.getLogger(ConditionalSubFlowExecutedAuthenticator.class);

@Override
public boolean matchCondition(AuthenticationFlowContext context) {
final AuthenticatorConfigModel configModel = context.getAuthenticatorConfig();
if (configModel == null || configModel.getConfig() == null) {
logger.warnf("No configuration defined for the conditional flow executed. Nothing executed.");
return false;
}

final String flowAlias = configModel.getConfig().get(ConditionalSubFlowExecutedAuthenticatorFactory.FLOW_TO_CHECK);
final boolean executed = !ConditionalSubFlowExecutedAuthenticatorFactory.CHECK_RESULT_NOT_EXECUTED.equals(
configModel.getConfig().get(ConditionalSubFlowExecutedAuthenticatorFactory.CHECK_RESULT));
if (flowAlias == null) {
logger.warnf("No flow configured in the option '%s'. Nothing executed.", ConditionalSubFlowExecutedAuthenticatorFactory.FLOW_TO_CHECK);
return !executed;
}

final RealmModel realm = context.getRealm();
final AuthenticationFlowModel flow = realm.getFlowByAlias(flowAlias);
if (flow == null) {
logger.warnf("No flow '%s' defined in the realm. Nothing executed.", flowAlias);
return !executed;
}

final AuthenticationExecutionModel exec = locateExecutionFlowToCheck(realm, context.getTopLevelFlow().getId(), flow.getId());
if (exec == null) {
logger.warnf("Cannot locate execution for flow '%s' in the top level flow '%s'. Nothing executed.", flowAlias, context.getTopLevelFlow().getAlias());
return !executed;
}

AuthenticationSessionModel.ExecutionStatus status = context.getAuthenticationSession().getExecutionStatus().get(exec.getId());
logger.tracef("The authentication status for the flow '%s' is %s", flowAlias, status);
return executed
? AuthenticationSessionModel.ExecutionStatus.SUCCESS.equals(status)
: !AuthenticationSessionModel.ExecutionStatus.SUCCESS.equals(status);
}

@Override
public void action(AuthenticationFlowContext context) {
// no-op
}

@Override
public boolean requiresUser() {
return false;
}

@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
// no-op
}

@Override
public void close() {
// no-op
}

private Stream<AuthenticationExecutionModel> flattened(RealmModel realm, AuthenticationExecutionModel flowExec) {
// flatten the execution model recursively only for flows
return Stream.concat(Stream.of(flowExec),
realm.getAuthenticationExecutionsStream(flowExec.getFlowId())
.filter(AuthenticationExecutionModel::isAuthenticatorFlow)
.flatMap(exec -> flattened(realm, exec)));
}

private AuthenticationExecutionModel locateExecutionFlowToCheck(RealmModel realm, String topFlowId, String flowId) {
return realm.getAuthenticationExecutionsStream(topFlowId)
.filter(AuthenticationExecutionModel::isAuthenticatorFlow)
.flatMap(exec -> flattened(realm, exec))
.filter(exec -> flowId.equals(exec.getFlowId()))
.findAny()
.orElse(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Copyright 2024 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.conditional;

import java.util.List;
import org.keycloak.Config.Scope;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationExecutionModel.Requirement;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;

/**
* <p>Conditional factory to know if a sub-flow was executed successfully in the authentication flow.</p>
*
* @author rmartinc
*/
public class ConditionalSubFlowExecutedAuthenticatorFactory implements ConditionalAuthenticatorFactory {

public static final String PROVIDER_ID = "conditional-sub-flow-executed";
public static final String FLOW_TO_CHECK = "flow_to_check";
public static final String CHECK_RESULT = "check_result";
public static final String CHECK_RESULT_EXECUTED = "executed";
public static final String CHECK_RESULT_NOT_EXECUTED = "not-executed";

@Override
public void init(Scope config) {
// no-op
}

@Override
public void postInit(KeycloakSessionFactory factory) {
// no-op
}

@Override
public void close() {
// no-op
}

@Override
public String getId() {
return PROVIDER_ID;
}

@Override
public String getDisplayType() {
return "Condition - sub-flow executed";
}

@Override
public boolean isConfigurable() {
return true;
}

@Override
public Requirement[] getRequirementChoices() {
return new Requirement[]{AuthenticationExecutionModel.Requirement.REQUIRED, AuthenticationExecutionModel.Requirement.DISABLED};
}

@Override
public boolean isUserSetupAllowed() {
return false;
}

@Override
public String getHelpText() {
return "Condition to evaluate if a sub-flow was executed successfully during the authentication process";
}

@Override
public List<ProviderConfigProperty> getConfigProperties() {
return ProviderConfigurationBuilder.create()
.property()
.name(FLOW_TO_CHECK)
.type(ProviderConfigProperty.STRING_TYPE)
.label("Flow name")
.helpText("The sub-flow name to check if it was executed.")
.required(true)
.add()
.property()
.name(CHECK_RESULT)
.type(ProviderConfigProperty.LIST_TYPE)
.label("Check result")
.helpText(
"""
When the condition evaluates to true.
If 'executed' returns true when the configured sub-flow was executed with output success, false otherwise.
If 'not-executed' returns false when the sub-flow was executed with output success, true otherwise.
"""
)
.required(true)
.options(List.of(CHECK_RESULT_EXECUTED, CHECK_RESULT_NOT_EXECUTED))
.defaultValue(CHECK_RESULT_EXECUTED)
.add()
.build();
}

@Override
public ConditionalAuthenticator getSingleton() {
return ConditionalSubFlowExecutedAuthenticator.SINGLETON;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -413,8 +413,7 @@ public Response copy(@Parameter(description="name of the existing authentication
data.put("id", copy.getId());
adminEvent.operation(OperationType.CREATE).resourcePath(session.getContext().getUri()).representation(data).success();

return Response.status(Response.Status.CREATED).build();

return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(copy.getId()).build()).build();
}

public static AuthenticationFlowModel copyFlow(KeycloakSession session, RealmModel realm, AuthenticationFlowModel flow, String newName) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ org.keycloak.authentication.authenticators.browser.PasswordFormFactory
org.keycloak.authentication.authenticators.browser.OTPFormAuthenticatorFactory
org.keycloak.authentication.authenticators.browser.SpnegoAuthenticatorFactory
org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticatorFactory
org.keycloak.authentication.authenticators.conditional.ConditionalSubFlowExecutedAuthenticatorFactory
org.keycloak.authentication.authenticators.conditional.ConditionalRoleAuthenticatorFactory
org.keycloak.authentication.authenticators.conditional.ConditionalUserConfiguredAuthenticatorFactory
org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory
Expand Down Expand Up @@ -54,4 +55,4 @@ org.keycloak.authentication.authenticators.access.AllowAccessAuthenticatorFactor
org.keycloak.authentication.authenticators.sessionlimits.UserSessionLimitsAuthenticatorFactory
org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticatorFactory
org.keycloak.organization.authentication.authenticators.browser.OrganizationAuthenticatorFactory
org.keycloak.authentication.authenticators.browser.PasskeysConditionalUIAuthenticatorFactory
org.keycloak.authentication.authenticators.browser.PasskeysConditionalUIAuthenticatorFactory
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ private List<Map<String, Object>> expectedAuthProviders() {

addProviderInfo(result, "idp-add-organization-member", "Organization Member Onboard", "Adds a federated user as a member of an organization");
addProviderInfo(result, "organization", "Organization Identity-First Login", "If organizations are enabled, automatically redirects users to the corresponding identity provider.");
addProviderInfo(result, "conditional-sub-flow-executed", "Condition - sub-flow executed", "Condition to evaluate if a sub-flow was executed successfully during the authentication process");

return result;
}
Expand Down
Loading

0 comments on commit bbca611

Please sign in to comment.