KEYCLOAK-10854: App-initiated actions Phase I

This commit is contained in:
Stan Silvert 2019-07-25 18:24:33 -04:00 committed by Bruno Oliveira da Silva
parent 6c79bdee41
commit bc818367a1
25 changed files with 142 additions and 101 deletions

View file

@ -21,5 +21,5 @@ package org.keycloak.authentication;
* @author Stan Silvert
*/
public enum InitiatedActionSupport {
SUPPORTED, NOT_SUPPORTED, CONSENT_REQUIRED
SUPPORTED, NOT_SUPPORTED
}

View file

@ -41,7 +41,8 @@ public interface RequiredActionContext {
CHALLENGE,
SUCCESS,
IGNORE,
FAILURE
FAILURE,
CANCELED_AIA
}
/**
@ -138,5 +139,11 @@ public interface RequiredActionContext {
*
*/
void ignore();
/**
* Mark application-initiated action as canceled by the user.
*
*/
void cancelAIA();
}

View file

@ -17,7 +17,9 @@
package org.keycloak.authentication;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.Provider;
import org.keycloak.sessions.AuthenticationSessionModel;
/**
* RequiredAction provider. Required actions are one-time actions that a user must perform before they are logged in.
@ -36,6 +38,18 @@ public interface RequiredActionProvider extends Provider {
return InitiatedActionSupport.NOT_SUPPORTED;
}
/**
* Callback to let the action know that an application-initiated action
* was canceled.
*
* @param session The Keycloak session.
* @param authSession The authentication session.
*
*/
default void initiatedActionCanceled(KeycloakSession session, AuthenticationSessionModel authSession) {
return;
}
/**
* Called every time a user authenticates. This checks to see if this required action should be triggered.
* The implementation of this method is responsible for setting the required action on the UserModel.

View file

@ -45,7 +45,6 @@ import org.keycloak.migration.migrators.MigrateTo4_0_0;
import org.keycloak.migration.migrators.MigrateTo4_2_0;
import org.keycloak.migration.migrators.MigrateTo4_6_0;
import org.keycloak.migration.migrators.MigrateTo6_0_0;
import org.keycloak.migration.migrators.MigrateTo7_0_0;
import org.keycloak.migration.migrators.Migration;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
@ -82,8 +81,7 @@ public class MigrationModelManager {
new MigrateTo4_0_0(),
new MigrateTo4_2_0(),
new MigrateTo4_6_0(),
new MigrateTo6_0_0(),
new MigrateTo7_0_0()
new MigrateTo6_0_0()
};
public static void migrate(KeycloakSession session) {

View file

@ -1,64 +0,0 @@
/*
* Copyright 2019 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.migration.migrators;
import org.jboss.logging.Logger;
import org.keycloak.migration.ModelVersion;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.representations.idm.RealmRepresentation;
/**
* Implements the migration necessary for version 6.0.0.
*
* @author <a href="mailto:sguilhen@redhat.com">Stefan Guilhen</a>
*/
public class MigrateTo7_0_0 implements Migration {
public static final ModelVersion VERSION = new ModelVersion("7.0.0");
private static final Logger LOG = Logger.getLogger(MigrateTo7_0_0.class);
@Override
public ModelVersion getVersion() {
return VERSION;
}
@Override
public void migrate(KeycloakSession session) {
session.realms().getRealms().stream().forEach(r -> {
migrateRealm(session, r, false);
});
}
@Override
public void migrateImport(KeycloakSession session, RealmModel realm, RealmRepresentation rep, boolean skipUserDependent) {
migrateRealm(session, realm, true);
}
protected void migrateRealm(KeycloakSession session, RealmModel realm, boolean jsn) {
ClientModel account = realm.getClientByClientId("account");
if (account != null) {
RoleModel role = account.addRole(AccountRoles.INITIATE_ACTION);
role.setDescription("${role_" + AccountRoles.INITIATE_ACTION + "}");
}
}
}

View file

@ -27,6 +27,6 @@ public interface AccountRoles {
String INITIATE_ACTION = "initiate-action";
String MANAGE_ACCOUNT_LINKS = "manage-account-links";
String[] ALL = {VIEW_PROFILE, MANAGE_ACCOUNT, INITIATE_ACTION};
String[] ALL = {VIEW_PROFILE, MANAGE_ACCOUNT};
}

View file

@ -63,6 +63,7 @@ public final class Constants {
public static final String KEY = "key";
public static final String KC_ACTION = "kc_action";
public static final String IS_AIA_REQUEST = "IS_AIA_REQUEST";
public static final String AIA_SILENT_CANCEL = "silent_cancel";
public static final String SKIP_LINK = "skipLink";
public static final String TEMPLATE_ATTR_ACTION_URI = "actionUri";

View file

@ -43,6 +43,14 @@ public interface LoginProtocol extends Provider {
* Login cancelled by the user
*/
CANCELLED_BY_USER,
/**
* Applications-initiated action was canceled by the user
*/
CANCELLED_AIA,
/**
* Applications-initiated action was canceled by the user. Do not send error.
*/
CANCELLED_AIA_SILENT,
/**
* Consent denied by the user
*/

View file

@ -136,6 +136,11 @@ public class RequiredActionContextResult implements RequiredActionContext {
public void ignore() {
status = Status.IGNORE;
}
@Override
public void cancelAIA() {
status = Status.CANCELED_AIA;
}
@Override
public URI getActionUrl(String code) {

View file

@ -269,9 +269,18 @@ public class OIDCLoginProtocol implements LoginProtocol {
String redirect = authSession.getRedirectUri();
String state = authSession.getClientNote(OIDCLoginProtocol.STATE_PARAM);
OIDCRedirectUriBuilder redirectUri = OIDCRedirectUriBuilder.fromUri(redirect, responseMode).addParam(OAuth2Constants.ERROR, translateError(error));
if (state != null)
OIDCRedirectUriBuilder redirectUri = OIDCRedirectUriBuilder.fromUri(redirect, responseMode);
if (error != Error.CANCELLED_AIA_SILENT) {
redirectUri.addParam(OAuth2Constants.ERROR, translateError(error));
}
if (error == Error.CANCELLED_AIA) {
redirectUri.addParam(OAuth2Constants.ERROR_DESCRIPTION, "User cancelled aplication-initiated action.");
}
if (state != null) {
redirectUri.addParam(OAuth2Constants.STATE, state);
}
new AuthenticationSessionManager(session).removeAuthenticationSession(realm, authSession, true);
return redirectUri.build();
}
@ -279,6 +288,8 @@ public class OIDCLoginProtocol implements LoginProtocol {
private String translateError(Error error) {
switch (error) {
case CANCELLED_BY_USER:
case CANCELLED_AIA:
return OAuthErrorException.INTERACTION_REQUIRED;
case CONSENT_DENIED:
return OAuthErrorException.ACCESS_DENIED;
case PASSIVE_INTERACTION_REQUIRED:

View file

@ -225,6 +225,7 @@ public class SamlProtocol implements LoginProtocol {
private JBossSAMLURIConstants translateErrorToSAMLStatus(Error error) {
switch (error) {
case CANCELLED_BY_USER:
case CANCELLED_AIA:
case CONSENT_DENIED:
return JBossSAMLURIConstants.STATUS_REQUEST_DENIED;
case PASSIVE_INTERACTION_REQUIRED:

View file

@ -45,7 +45,6 @@ import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.ActionTokenKeyModel;
import org.keycloak.models.ActionTokenStoreProvider;
import org.keycloak.models.AuthenticatedClientSessionModel;
@ -66,6 +65,7 @@ import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocol.Error;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.ForbiddenException;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.Urls;
import org.keycloak.services.messages.Messages;
@ -96,6 +96,7 @@ import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.keycloak.models.AccountRoles;
import static org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX;
@ -132,6 +133,7 @@ public class AuthenticationManager {
private static final TokenTypeCheck VALIDATE_IDENTITY_COOKIE = new TokenTypeCheck(TokenUtil.TOKEN_TYPE_KEYCLOAK_ID);
private static final String AIA_REQUEST = LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + Constants.KC_ACTION;
public static final String IS_AIA_REQUEST = LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + Constants.IS_AIA_REQUEST;
public static final String IS_SILENT_CANCEL = LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + Constants.AIA_SILENT_CANCEL;
public static boolean isSessionValid(RealmModel realm, UserSessionModel userSession) {
if (userSession == null) {
@ -1144,6 +1146,11 @@ public class AuthenticationManager {
public void ignore() {
throw new RuntimeException("Not allowed to call ignore() within evaluateTriggers()");
}
@Override
public void cancelAIA() {
throw new RuntimeException("Not allowed to call cancelAIA() within evaluateTriggers()");
}
};
evaluateApplicationInitiatedActionTrigger(session, provider, model, authSession);
@ -1166,11 +1173,10 @@ public class AuthenticationManager {
// make sure you are evaluating the action that was requested
if (!aia.equalsIgnoreCase(model.getProviderId())) return;
if (session.getContext().getClient().getRole(AccountRoles.INITIATE_ACTION) == null) {
logger.error("Client must have initiate-action role to perform application-initiated action.");
return;
if (session.getContext().getClient().getRole(AccountRoles.MANAGE_ACCOUNT) == null) {
throw new ForbiddenException("Client must have manage-account role to perform application-initiated actions.");
}
authSession.addRequiredAction(model.getProviderId());
authSession.removeClientNote(AIA_REQUEST); // keep this from being executed twice
authSession.setClientNote(IS_AIA_REQUEST, "true");

View file

@ -102,6 +102,7 @@ import java.util.Map;
import static org.keycloak.authentication.actiontoken.DefaultActionToken.ACTION_TOKEN_BASIC_CHECKS;
import static org.keycloak.services.managers.AuthenticationManager.IS_AIA_REQUEST;
import static org.keycloak.services.managers.AuthenticationManager.IS_SILENT_CANCEL;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -993,7 +994,8 @@ public class LoginActionsService {
Response response;
if (isCancelAppInitiatedAction(authSession, context)) {
context.failure();
provider.initiatedActionCanceled(session, authSession);
context.cancelAIA();
} else {
provider.processAction(context);
}
@ -1014,15 +1016,11 @@ public class LoginActionsService {
} else if (context.getStatus() == RequiredActionContext.Status.CHALLENGE) {
response = context.getChallenge();
} else if (context.getStatus() == RequiredActionContext.Status.FAILURE) {
LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, authSession.getProtocol());
protocol.setRealm(context.getRealm())
.setHttpHeaders(context.getHttpRequest().getHttpHeaders())
.setUriInfo(context.getUriInfo())
.setEventBuilder(event);
event.detail(Details.CUSTOM_REQUIRED_ACTION, action);
response = protocol.sendError(authSession, Error.CONSENT_DENIED);
event.error(Errors.REJECTED_BY_USER);
response = interruptionResponse(context, authSession, action, Error.CONSENT_DENIED);
} else if (isSilentAIACancel(authSession, context)) {
response = interruptionResponse(context, authSession, action, Error.CANCELLED_AIA_SILENT);
} else if (context.getStatus() == RequiredActionContext.Status.CANCELED_AIA) {
response = interruptionResponse(context, authSession, action, Error.CANCELLED_AIA);
} else {
throw new RuntimeException("Unreachable");
}
@ -1030,6 +1028,19 @@ public class LoginActionsService {
return BrowserHistoryHelper.getInstance().saveResponseAndRedirect(session, authSession, response, true, request);
}
private Response interruptionResponse(RequiredActionContextResult context, AuthenticationSessionModel authSession, String action, Error error) {
LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, authSession.getProtocol());
protocol.setRealm(context.getRealm())
.setHttpHeaders(context.getHttpRequest().getHttpHeaders())
.setUriInfo(context.getUriInfo())
.setEventBuilder(event);
event.detail(Details.CUSTOM_REQUIRED_ACTION, action);
event.error(Errors.REJECTED_BY_USER);
return protocol.sendError(authSession, error);
}
private boolean isCancelAppInitiatedAction(AuthenticationSessionModel authSession, RequiredActionContextResult context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
@ -1038,5 +1049,13 @@ public class LoginActionsService {
return isAIARequest && userRequestedCancelAIA;
}
private boolean isSilentAIACancel(AuthenticationSessionModel authSession, RequiredActionContextResult context) {
String silentCancel = authSession.getClientNote(IS_SILENT_CANCEL);
boolean isSilentCancel = "true".equalsIgnoreCase(silentCancel);
boolean isAIACancel = isCancelAppInitiatedAction(authSession, context);
return isSilentCancel && isAIACancel;
}
}

View file

@ -50,8 +50,13 @@ public abstract class AbstractAppInitiatedActionTest extends AbstractTestRealmKe
}
protected void doAIA() {
doAIA(false);
}
protected void doAIA(boolean silentCancel) {
UriBuilder builder = OIDCLoginProtocolService.authUrl(authServerPage.createUriBuilder());
String uri = builder.queryParam("kc_action", this.aiaAction)
.queryParam("silent_cancel", Boolean.toString(silentCancel))
.queryParam("response_type", "code")
.queryParam("client_id", "test-app")
.queryParam("scope", "openid")
@ -64,4 +69,16 @@ public abstract class AbstractAppInitiatedActionTest extends AbstractTestRealmKe
protected void assertRedirectSuccess() {
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
}
protected void assertCancelMessage() {
String url = this.driver.getCurrentUrl();
Assert.assertTrue("Expected 'error=interaction_required' in url", url.contains("error=interaction_required"));
Assert.assertTrue("Expected 'error_description=User+cancelled+aplication-initiated+action.' in url", url.contains("error_description=User+cancelled+aplication-initiated+action."));
}
protected void assertSilentCancelMessage() {
String url = this.driver.getCurrentUrl();
Assert.assertFalse("Expected no 'error=' in url", url.contains("error="));
Assert.assertFalse("Expected no 'error_description=' in url", url.contains("error_description="));
}
}

View file

@ -50,6 +50,7 @@ public class AppInitiatedActionCancelTest extends AbstractAppInitiatedActionTest
updateProfilePage.assertCurrent();
updateProfilePage.cancel();
assertRedirectSuccess();
assertCancelMessage();
appPage.logout();
@ -58,4 +59,14 @@ public class AppInitiatedActionCancelTest extends AbstractAppInitiatedActionTest
loginPage.login("test-user@localhost", "password");
updateProfilePage.assertCurrent();
}
@Test
public void silentCancelUpdateProfile() {
doAIA(true);
loginPage.login("test-user@localhost", "password");
updateProfilePage.assertCurrent();
updateProfilePage.cancel();
assertRedirectSuccess();
assertSilentCancelMessage();
}
}

View file

@ -83,6 +83,7 @@ public class AppInitiatedActionResetPasswordTest extends AbstractAppInitiatedAct
changePasswordPage.cancel();
assertRedirectSuccess();
assertCancelMessage();
}
}

View file

@ -17,10 +17,12 @@
package org.keycloak.testsuite.actions;
import java.util.List;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Test;
import org.keycloak.models.AccountRoles;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.testsuite.pages.ErrorPage;
/**
*
@ -28,6 +30,9 @@ import org.keycloak.representations.idm.RoleRepresentation;
*/
public class AppInitiatedActionRoleTest extends AbstractAppInitiatedActionTest {
@Page
protected ErrorPage errorPage;
public AppInitiatedActionRoleTest() {
super("update_profile");
}
@ -36,15 +41,15 @@ public class AppInitiatedActionRoleTest extends AbstractAppInitiatedActionTest {
public void configureTestRealm(RealmRepresentation testRealm) {
List<RoleRepresentation> roleList = testRealm.getRoles().getClient().get("test-app");
RoleRepresentation initiateActionRole = null;
RoleRepresentation manageAccountRole = null;
for (RoleRepresentation role : roleList) {
if (role.getName().equals(AccountRoles.INITIATE_ACTION)) {
initiateActionRole = role;
if (role.getName().equals(AccountRoles.MANAGE_ACCOUNT)) {
manageAccountRole = role;
break;
}
}
roleList.remove(initiateActionRole);
roleList.remove(manageAccountRole);
}
@Test
@ -52,6 +57,6 @@ public class AppInitiatedActionRoleTest extends AbstractAppInitiatedActionTest {
loginPage.open();
loginPage.login("test-user@localhost", "password");
doAIA();
assertRedirectSuccess(); // update profile screen does not appear
errorPage.assertCurrent();
}
}

View file

@ -127,6 +127,7 @@ public class AppInitiatedActionTotpSetupTest extends AbstractAppInitiatedActionT
totpPage.cancel();
assertRedirectSuccess();
assertCancelMessage();
}
@Test

View file

@ -133,6 +133,7 @@ public class AppInitiatedActionUpdateProfileTest extends AbstractAppInitiatedAct
updateProfilePage.cancel();
assertRedirectSuccess();
assertCancelMessage();
// assert nothing was updated in persistent store
UserRepresentation user = ActionUtil.findUserWithAdminClient(adminClient, "test-user@localhost");

View file

@ -123,7 +123,7 @@ public class AddUserTest extends AbstractKeycloakTest {
}
List<RoleRepresentation> accountRoles = userResource.roles().clientLevel(accountId).listAll();
assertRoles(accountRoles, "initiate-action", "view-profile", "manage-account");
assertRoles(accountRoles, "view-profile", "manage-account");
} finally {
userResource.remove();
}

View file

@ -438,7 +438,7 @@ public class ClientTest extends AbstractAdminTest {
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listAll(), AccountRoles.VIEW_PROFILE);
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listEffective(), AccountRoles.VIEW_PROFILE);
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listAvailable(), AccountRoles.INITIATE_ACTION, AccountRoles.MANAGE_ACCOUNT, AccountRoles.MANAGE_ACCOUNT_LINKS);
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listAvailable(), AccountRoles.MANAGE_ACCOUNT, AccountRoles.MANAGE_ACCOUNT_LINKS);
Assert.assertNames(scopesResource.getAll().getRealmMappings(), "role1");
Assert.assertNames(scopesResource.getAll().getClientMappings().get(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).getMappings(), AccountRoles.VIEW_PROFILE);
@ -453,7 +453,7 @@ public class ClientTest extends AbstractAdminTest {
Assert.assertNames(scopesResource.realmLevel().listEffective());
Assert.assertNames(scopesResource.realmLevel().listAvailable(), "offline_access", Constants.AUTHZ_UMA_AUTHORIZATION, "role1", "role2");
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listAll());
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listAvailable(), AccountRoles.INITIATE_ACTION, AccountRoles.VIEW_PROFILE, AccountRoles.MANAGE_ACCOUNT, AccountRoles.MANAGE_ACCOUNT_LINKS);
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listAvailable(), AccountRoles.VIEW_PROFILE, AccountRoles.MANAGE_ACCOUNT, AccountRoles.MANAGE_ACCOUNT_LINKS);
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listEffective());
}

View file

@ -1409,7 +1409,7 @@ public class UserTest extends AbstractAdminTest {
assertNames(all.getRealmMappings(), "realm-role", "realm-composite", "user", "offline_access", Constants.AUTHZ_UMA_AUTHORIZATION);
assertEquals(2, all.getClientMappings().size());
assertNames(all.getClientMappings().get("myclient").getMappings(), "client-role", "client-composite");
assertNames(all.getClientMappings().get("account").getMappings(), "initiate-action", "manage-account", "view-profile");
assertNames(all.getClientMappings().get("account").getMappings(), "manage-account", "view-profile");
// Remove realm role
RoleRepresentation realmRoleRep = realm.roles().get("realm-role").toRepresentation();

View file

@ -129,7 +129,7 @@ public class KcAdmSessionTest extends AbstractAdmCliTest {
realmMappings = StreamSupport.stream(clientRoles.get("account").get("mappings").spliterator(), false)
.map(o -> o.get("name").asText()).sorted().collect(Collectors.toList());
Assert.assertEquals(Arrays.asList("initiate-action", "manage-account", "view-profile"), realmMappings);
Assert.assertEquals(Arrays.asList("manage-account", "view-profile"), realmMappings);
realmMappings = StreamSupport.stream(clientRoles.get("realm-management").get("mappings").spliterator(), false)
.map(o -> o.get("name").asText()).sorted().collect(Collectors.toList());

View file

@ -416,9 +416,9 @@
],
"client" : {
"test-app" : [
{
"name" : "initiate-action",
"description": "Allow application-initiated actions"
{
"name": "manage-account",
"description": "Allows application-initiated actions."
},
{
"name": "customer-user",

View file

@ -79,7 +79,6 @@ role_manage-clients=Manage clients
role_manage-events=Manage events
role_view-profile=View profile
role_manage-account=Manage account
role_initiate-action=Initiate action
role_manage-account-links=Manage account links
role_read-token=Read token
role_offline-access=Offline access