diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js index 12c0eb46d6..b2a48703e6 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/js/controllers/users.js @@ -304,7 +304,9 @@ module.controller('UserTabCtrl', function($scope, $location, Dialog, Notificatio }; }); -module.controller('UserDetailCtrl', function($scope, realm, user, BruteForceUser, User, UserExecuteActionsEmail, UserFederationInstances, UserImpersonation, RequiredActions, $location, Dialog, Notifications) { +module.controller('UserDetailCtrl', function($scope, realm, user, BruteForceUser, User, + UserFederationInstances, UserImpersonation, RequiredActions, + $location, Dialog, Notifications) { $scope.realm = realm; $scope.create = !user.id; $scope.editUsername = $scope.create || $scope.realm.editUsernameAllowed; @@ -374,36 +376,12 @@ module.controller('UserDetailCtrl', function($scope, realm, user, BruteForceUser } }); - - /*[ - {id: "VERIFY_EMAIL", text: "Verify Email"}, - {id: "UPDATE_PROFILE", text: "Update Profile"}, - {id: "CONFIGURE_TOTP", text: "Configure Totp"}, - {id: "UPDATE_PASSWORD", text: "Update Password"} - ]; - */ - $scope.$watch('user', function() { if (!angular.equals($scope.user, user)) { $scope.changed = true; } }, true); - $scope.sendExecuteActionsEmail = function() { - if ($scope.changed) { - Dialog.message("Cannot send email", "You must save your current changes before you can send an email"); - return; - } - Dialog.confirm('Send Email', 'Are you sure you want to send email to user?', function() { - UserExecuteActionsEmail.update({ realm: realm.realm, userId: user.id }, { }, function() { - Notifications.success("Email sent to user"); - }, function() { - Notifications.error("Failed to send email to user"); - }); - }); - }; - - $scope.save = function() { convertAttributeValuesToLists(); @@ -476,7 +454,7 @@ module.controller('UserDetailCtrl', function($scope, realm, user, BruteForceUser } }); -module.controller('UserCredentialsCtrl', function($scope, realm, user, User, UserCredentials, Notifications, Dialog) { +module.controller('UserCredentialsCtrl', function($scope, realm, user, RequiredActions, User, UserExecuteActionsEmail, UserCredentials, Notifications, Dialog) { console.log('UserCredentialsCtrl'); $scope.realm = realm; @@ -487,6 +465,18 @@ module.controller('UserCredentialsCtrl', function($scope, realm, user, User, Use if(!!user.totp){ $scope.isTotp = user.totp; } + // ID - Name map for required actions. IDs are enum names. + RequiredActions.query({realm: realm.realm}, function(data) { + $scope.userReqActionList = []; + for (var i = 0; i < data.length; i++) { + console.log("listed required action: " + data[i].name); + if (data[i].enabled) { + var item = data[i]; + $scope.userReqActionList.push(item); + } + } + + }); $scope.resetPassword = function() { if ($scope.pwdChange) { @@ -528,6 +518,24 @@ module.controller('UserCredentialsCtrl', function($scope, realm, user, User, Use }); }; + $scope.emailActions = []; + + $scope.sendExecuteActionsEmail = function() { + if ($scope.changed) { + Dialog.message("Cannot send email", "You must save your current changes before you can send an email"); + return; + } + Dialog.confirm('Send Email', 'Are you sure you want to send email to user?', function() { + UserExecuteActionsEmail.update({ realm: realm.realm, userId: user.id }, $scope.emailActions, function() { + Notifications.success("Email sent to user"); + $scope.emailActions = []; + }, function() { + Notifications.error("Failed to send email to user"); + }); + }); + }; + + $scope.$watch('user', function() { if (!angular.equals($scope.user, user)) { diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-credentials.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-credentials.html old mode 100644 new mode 100755 index e47376ad52..13c079dd36 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-credentials.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-credentials.html @@ -44,6 +44,26 @@ + +
+
+ + +
+ +
+ Set of actions to execute when sending the user a Reset Actions Email. 'Verify email' sends an email to the user to verify their email address. 'Update profile' requires user to enter in new personal information. 'Update password' requires user to enter in a new password. 'Configure TOTP' requires setup of a mobile password generator. +
+
+ + +
+ +
+ Sends an email to user with an embedded link. Clicking on link will allow the user to execute the reset actions. They will not have to login prior to this. For example, set the action to update password, click this button, and the user will be able to change their password without logging in. +
diff --git a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-detail.html b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-detail.html index e00d928c59..db17683b1c 100755 --- a/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-detail.html +++ b/forms/common-themes/src/main/resources/theme/base/admin/resources/partials/user-detail.html @@ -99,14 +99,6 @@ Require an action when the user logs in. 'Verify email' sends an email to the user to verify their email address. 'Update profile' requires user to enter in new personal information. 'Update password' requires user to enter in a new password. 'Configure TOTP' requires setup of a mobile password generator. -
- - -
- -
- Sends an email to user with an embedded link. Clicking on link will allow the user to execute all their required actions. They will not have to login prior to this. For example, set the required action to update password, click this button, and the user will be able to change their password without logging in. -
diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java index 1cbf8887e1..e21ee7999c 100755 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/UserResource.java @@ -48,11 +48,11 @@ public interface UserResource { @PUT @Path("execute-actions-email") - public void executeActionsEmail(); + public void executeActionsEmail(List actions); @PUT @Path("execute-actions-email") - public void executeActionsEmail(@QueryParam("client_id") String clientId); + public void executeActionsEmail(@QueryParam("client_id") String clientId, List actions); @PUT @Path("send-verify-email") diff --git a/model/api/src/main/java/org/keycloak/models/ClientSessionModel.java b/model/api/src/main/java/org/keycloak/models/ClientSessionModel.java index c878927d90..b4f638ddff 100755 --- a/model/api/src/main/java/org/keycloak/models/ClientSessionModel.java +++ b/model/api/src/main/java/org/keycloak/models/ClientSessionModel.java @@ -53,6 +53,22 @@ public interface ClientSessionModel { public void removeNote(String name); public Map getNotes(); + /** + * Required actions that are attached to this client session. + * + * @return + */ + Set getRequiredActions(); + + void addRequiredAction(String action); + + void removeRequiredAction(String action); + + void addRequiredAction(UserModel.RequiredAction action); + + void removeRequiredAction(UserModel.RequiredAction action); + + /** * These are notes you want applied to the UserSessionModel when the client session is attached to it. * diff --git a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ClientSessionAdapter.java b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ClientSessionAdapter.java index 0b804b4f8a..179a1f0211 100755 --- a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ClientSessionAdapter.java +++ b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/ClientSessionAdapter.java @@ -12,6 +12,7 @@ import org.keycloak.models.sessions.infinispan.entities.SessionEntity; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -200,6 +201,38 @@ public class ClientSessionAdapter implements ClientSessionModel { } + @Override + public Set getRequiredActions() { + Set copy = new HashSet<>(); + copy.addAll(entity.getRequiredActions()); + return copy; + } + + @Override + public void addRequiredAction(String action) { + entity.getRequiredActions().add(action); + update(); + + } + + @Override + public void removeRequiredAction(String action) { + entity.getRequiredActions().remove(action); + update(); + + } + + @Override + public void addRequiredAction(UserModel.RequiredAction action) { + addRequiredAction(action.name()); + + } + + @Override + public void removeRequiredAction(UserModel.RequiredAction action) { + removeRequiredAction(action.name()); + } + void update() { provider.getTx().replace(cache, entity.getId(), entity); } diff --git a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/ClientSessionAdapter.java b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/ClientSessionAdapter.java index 1d806c14d1..dc6d0be91a 100755 --- a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/ClientSessionAdapter.java +++ b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/ClientSessionAdapter.java @@ -11,6 +11,7 @@ import org.keycloak.models.sessions.infinispan.compat.entities.UserSessionEntity import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -197,4 +198,35 @@ public class ClientSessionAdapter implements ClientSessionModel { else entity.setAuthUserId(user.getId()); } + + @Override + public Set getRequiredActions() { + Set copy = new HashSet<>(); + copy.addAll(entity.getRequiredActions()); + return copy; + } + + @Override + public void addRequiredAction(String action) { + entity.getRequiredActions().add(action); + + } + + @Override + public void removeRequiredAction(String action) { + entity.getRequiredActions().remove(action); + + } + + @Override + public void addRequiredAction(UserModel.RequiredAction action) { + addRequiredAction(action.name()); + + } + + @Override + public void removeRequiredAction(UserModel.RequiredAction action) { + removeRequiredAction(action.name()); + } + } diff --git a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/entities/ClientSessionEntity.java b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/entities/ClientSessionEntity.java index 73336b74ab..e178a35c9e 100755 --- a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/entities/ClientSessionEntity.java +++ b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/compat/entities/ClientSessionEntity.java @@ -3,6 +3,7 @@ package org.keycloak.models.sessions.infinispan.compat.entities; import org.keycloak.models.ClientSessionModel; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Set; @@ -28,6 +29,8 @@ public class ClientSessionEntity { private Set protocolMappers; private Map notes = new HashMap<>(); private Map userSessionNotes = new HashMap<>(); + private Set requiredActions = new HashSet<>(); + public String getId() { return id; @@ -133,6 +136,10 @@ public class ClientSessionEntity { return userSessionNotes; } + public Set getRequiredActions() { + return requiredActions; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ClientSessionEntity.java b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ClientSessionEntity.java index a00e805029..5ddea31521 100755 --- a/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ClientSessionEntity.java +++ b/model/sessions-infinispan/src/main/java/org/keycloak/models/sessions/infinispan/entities/ClientSessionEntity.java @@ -3,6 +3,9 @@ package org.keycloak.models.sessions.infinispan.entities; import org.keycloak.models.ClientSessionModel; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; import java.util.Map; import java.util.Set; @@ -31,6 +34,8 @@ public class ClientSessionEntity extends SessionEntity { private Map userSessionNotes; private Map authenticatorStatus = new HashMap<>(); private String authUserId; + private Set requiredActions = new HashSet<>(); + public String getClient() { return client; @@ -136,5 +141,7 @@ public class ClientSessionEntity extends SessionEntity { this.userSessionNotes = userSessionNotes; } - + public Set getRequiredActions() { + return requiredActions; + } } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetOTP.java b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetOTP.java index d99dd9fb27..9ee834c034 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetOTP.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetOTP.java @@ -19,7 +19,7 @@ public class ResetOTP extends AbstractSetRequiredActionAuthenticator { if (context.getExecution().isRequired() || (context.getExecution().isOptional() && configuredFor(context))) { - context.getUser().addRequiredAction(UserModel.RequiredAction.CONFIGURE_TOTP); + context.getClientSession().addRequiredAction(UserModel.RequiredAction.CONFIGURE_TOTP); } context.success(); } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetPassword.java b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetPassword.java index f41e5bd949..468f4a0da1 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetPassword.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetPassword.java @@ -27,7 +27,7 @@ public class ResetPassword extends AbstractSetRequiredActionAuthenticator { if (context.getExecution().isRequired() || (context.getExecution().isOptional() && configuredFor(context))) { - context.getUser().addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD); + context.getClientSession().addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD); } context.success(); } diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index 70f10e74b1..3790d5a4f7 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -441,32 +441,14 @@ public class AuthenticationManager { event.detail(Details.CODE_ID, clientSession.getId()); Set requiredActions = user.getRequiredActions(); - for (String action : requiredActions) { - RequiredActionProviderModel model = realm.getRequiredActionProviderByAlias(action); - RequiredActionFactory factory = (RequiredActionFactory)session.getKeycloakSessionFactory().getProviderFactory(RequiredActionProvider.class, model.getProviderId()); - if (factory == null) { - throw new RuntimeException("Unable to find factory for Required Action: " + model.getProviderId() + " did you forget to declare it in a META-INF/services file?"); - } - RequiredActionProvider actionProvider = factory.create(session); - RequiredActionContextResult context = new RequiredActionContextResult(userSession, clientSession, realm, event, session, request, user, factory); - actionProvider.requiredActionChallenge(context); + Response action = executionActions(session, userSession, clientSession, request, event, realm, user, requiredActions); + if (action != null) return action; + + // executionActions() method should remove any duplicate actions that might be in the clientSession + requiredActions = clientSession.getRequiredActions(); + action = executionActions(session, userSession, clientSession, request, event, realm, user, requiredActions); + if (action != null) return action; - if (context.getStatus() == RequiredActionContext.Status.FAILURE) { - LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, context.getClientSession().getAuthMethod()); - protocol.setRealm(context.getRealm()) - .setHttpHeaders(context.getHttpRequest().getHttpHeaders()) - .setUriInfo(context.getUriInfo()); - event.error(Errors.REJECTED_BY_USER); - return protocol.consentDenied(context.getClientSession()); - } - else if (context.getStatus() == RequiredActionContext.Status.CHALLENGE) { - return context.getChallenge(); - } - else if (context.getStatus() == RequiredActionContext.Status.SUCCESS) { - event.clone().event(EventType.CUSTOM_REQUIRED_ACTION).detail(Details.CUSTOM_REQUIRED_ACTION, factory.getId()).success(); - clientSession.getUserSession().getUser().removeRequiredAction(factory.getId()); - } - } if (client.isConsentRequired()) { UserConsentModel grantedConsent = user.getConsentByClient(client.getId()); @@ -516,6 +498,40 @@ public class AuthenticationManager { } + protected static Response executionActions(KeycloakSession session, UserSessionModel userSession, ClientSessionModel clientSession, + HttpRequest request, EventBuilder event, RealmModel realm, UserModel user, + Set requiredActions) { + for (String action : requiredActions) { + RequiredActionProviderModel model = realm.getRequiredActionProviderByAlias(action); + RequiredActionFactory factory = (RequiredActionFactory)session.getKeycloakSessionFactory().getProviderFactory(RequiredActionProvider.class, model.getProviderId()); + if (factory == null) { + throw new RuntimeException("Unable to find factory for Required Action: " + model.getProviderId() + " did you forget to declare it in a META-INF/services file?"); + } + RequiredActionProvider actionProvider = factory.create(session); + RequiredActionContextResult context = new RequiredActionContextResult(userSession, clientSession, realm, event, session, request, user, factory); + actionProvider.requiredActionChallenge(context); + + if (context.getStatus() == RequiredActionContext.Status.FAILURE) { + LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, context.getClientSession().getAuthMethod()); + protocol.setRealm(context.getRealm()) + .setHttpHeaders(context.getHttpRequest().getHttpHeaders()) + .setUriInfo(context.getUriInfo()); + event.error(Errors.REJECTED_BY_USER); + return protocol.consentDenied(context.getClientSession()); + } + else if (context.getStatus() == RequiredActionContext.Status.CHALLENGE) { + return context.getChallenge(); + } + else if (context.getStatus() == RequiredActionContext.Status.SUCCESS) { + event.clone().event(EventType.CUSTOM_REQUIRED_ACTION).detail(Details.CUSTOM_REQUIRED_ACTION, factory.getId()).success(); + // don't have to perform the same action twice, so remove it from both the user and session required actions + clientSession.getUserSession().getUser().removeRequiredAction(factory.getId()); + clientSession.removeRequiredAction(factory.getId()); + } + } + return null; + } + public static void evaluateRequiredActionTriggers(final KeycloakSession session, final UserSessionModel userSession, final ClientSessionModel clientSession, final ClientConnection clientConnection, final HttpRequest request, final UriInfo uriInfo, final EventBuilder event, final RealmModel realm, final UserModel user) { // see if any required actions need triggering, i.e. an expired password diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java index 86adcdc3b5..6d9fee952e 100755 --- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java @@ -684,6 +684,8 @@ public class LoginActionsService { provider.processAction(context); if (context.getStatus() == RequiredActionContext.Status.SUCCESS) { event.clone().success(); + // do both + clientSession.removeRequiredAction(factory.getId()); clientSession.getUserSession().getUser().removeRequiredAction(factory.getId()); event.event(EventType.LOGIN); return AuthenticationManager.nextActionAfterAuthentication(session, clientSession.getUserSession(), clientSession, clientConnection, request, uriInfo, event); diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java index 3158b69d69..7aeabc21d7 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UsersResource.java @@ -837,7 +837,10 @@ public class UsersResource { @Path("{id}/execute-actions-email") @PUT @Consumes(MediaType.APPLICATION_JSON) - public Response executeActionsEmail(@PathParam("id") String id, @QueryParam(OIDCLoginProtocol.REDIRECT_URI_PARAM) String redirectUri, @QueryParam(OIDCLoginProtocol.CLIENT_ID_PARAM) String clientId) { + public Response executeActionsEmail(@PathParam("id") String id, + @QueryParam(OIDCLoginProtocol.REDIRECT_URI_PARAM) String redirectUri, + @QueryParam(OIDCLoginProtocol.CLIENT_ID_PARAM) String clientId, + List actions) { auth.requireManage(); UserModel user = session.users().getUserById(id, realm); @@ -850,6 +853,9 @@ public class UsersResource { } ClientSessionModel clientSession = createClientSession(user, redirectUri, clientId); + for (String action : actions) { + clientSession.addRequiredAction(action); + } ClientSessionCode accessCode = new ClientSessionCode(realm, clientSession); accessCode.setAction(ClientSessionModel.Action.EXECUTE_ACTIONS.name()); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/admin/UserTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/admin/UserTest.java index 25a98a0b8b..de1b2ca3cc 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/admin/UserTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/admin/UserTest.java @@ -14,6 +14,7 @@ import javax.ws.rs.ClientErrorException; import javax.ws.rs.core.Response; import java.util.ArrayList; +import java.util.LinkedList; import java.util.List; import static org.junit.Assert.assertEquals; @@ -359,9 +360,9 @@ public class UserTest extends AbstractClientTest { String id = ApiUtil.getCreatedId(response); response.close(); UserResource user = realm.users().get(id); - + List actions = new LinkedList<>(); try { - user.executeActionsEmail(); + user.executeActionsEmail(actions); fail("Expected failure"); } catch (ClientErrorException e) { assertEquals(400, e.getResponse().getStatus()); @@ -374,7 +375,7 @@ public class UserTest extends AbstractClientTest { userRep.setEmail("user1@localhost"); userRep.setEnabled(false); user.update(userRep); - user.executeActionsEmail(); + user.executeActionsEmail(actions); fail("Expected failure"); } catch (ClientErrorException e) { assertEquals(400, e.getResponse().getStatus()); @@ -385,7 +386,7 @@ public class UserTest extends AbstractClientTest { try { userRep.setEnabled(true); user.update(userRep); - user.executeActionsEmail("invalidClientId"); + user.executeActionsEmail("invalidClientId", actions); fail("Expected failure"); } catch (ClientErrorException e) { assertEquals(400, e.getResponse().getStatus());