client session required actions

This commit is contained in:
Bill Burke 2015-09-02 16:30:16 -04:00
parent 38a721425d
commit 7d4b93e01e
15 changed files with 209 additions and 69 deletions

View file

@ -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.realm = realm;
$scope.create = !user.id; $scope.create = !user.id;
$scope.editUsername = $scope.create || $scope.realm.editUsernameAllowed; $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() { $scope.$watch('user', function() {
if (!angular.equals($scope.user, user)) { if (!angular.equals($scope.user, user)) {
$scope.changed = true; $scope.changed = true;
} }
}, 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() { $scope.save = function() {
convertAttributeValuesToLists(); 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'); console.log('UserCredentialsCtrl');
$scope.realm = realm; $scope.realm = realm;
@ -487,6 +465,18 @@ module.controller('UserCredentialsCtrl', function($scope, realm, user, User, Use
if(!!user.totp){ if(!!user.totp){
$scope.isTotp = 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() { $scope.resetPassword = function() {
if ($scope.pwdChange) { 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() { $scope.$watch('user', function() {
if (!angular.equals($scope.user, user)) { if (!angular.equals($scope.user, user)) {

View file

@ -44,6 +44,26 @@
<button class="btn btn-danger" type="submit" data-ng-click="removeTotp()" tooltip-trigger="mouseover mouseout" tooltip="Remove one time password generator for user." tooltip-placement="right">Remove TOTP</button> <button class="btn btn-danger" type="submit" data-ng-click="removeTotp()" tooltip-trigger="mouseover mouseout" tooltip="Remove one time password generator for user." tooltip-placement="right">Remove TOTP</button>
</div> </div>
</div> </div>
</fieldset >
<fieldset class="border-top" data-ng-show="user.email">
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="reqActions">Reset Actions</label>
<div class="col-md-6">
<select ui-select2 id="reqActions" ng-model="emailActions" data-placeholder="Select an action..." multiple>
<option ng-repeat="action in userReqActionList" value="{{action.alias}}">{{action.name}}</option>
</select>
</div>
<kc-tooltip>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.</kc-tooltip>
</div>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="reqActionsEmail">Reset Actions Email</label>
<div class="col-md-6">
<button id="reqActionsEmail" class="btn btn-default" data-ng-click="sendExecuteActionsEmail()">Send Email</button>
</div>
<kc-tooltip>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.</kc-tooltip>
</div>
</fieldset> </fieldset>
</form> </form>
</div> </div>

View file

@ -99,14 +99,6 @@
</div> </div>
<kc-tooltip>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.</kc-tooltip> <kc-tooltip>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.</kc-tooltip>
</div> </div>
<div class="form-group clearfix" data-ng-show="!create && user.email">
<label class="col-md-2 control-label" for="reqActionsEmail">Required Actions Email</label>
<div class="col-md-6">
<button id="reqActionsEmail" class="btn btn-default" data-ng-click="sendExecuteActionsEmail()">Send Email</button>
</div>
<kc-tooltip>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.</kc-tooltip>
</div>
<div class="form-group clearfix" data-ng-if="realm.internationalizationEnabled"> <div class="form-group clearfix" data-ng-if="realm.internationalizationEnabled">
<label class="col-md-2 control-label" for="locale">Locale</label> <label class="col-md-2 control-label" for="locale">Locale</label>

View file

@ -48,11 +48,11 @@ public interface UserResource {
@PUT @PUT
@Path("execute-actions-email") @Path("execute-actions-email")
public void executeActionsEmail(); public void executeActionsEmail(List<String> actions);
@PUT @PUT
@Path("execute-actions-email") @Path("execute-actions-email")
public void executeActionsEmail(@QueryParam("client_id") String clientId); public void executeActionsEmail(@QueryParam("client_id") String clientId, List<String> actions);
@PUT @PUT
@Path("send-verify-email") @Path("send-verify-email")

View file

@ -53,6 +53,22 @@ public interface ClientSessionModel {
public void removeNote(String name); public void removeNote(String name);
public Map<String, String> getNotes(); public Map<String, String> getNotes();
/**
* Required actions that are attached to this client session.
*
* @return
*/
Set<String> 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. * These are notes you want applied to the UserSessionModel when the client session is attached to it.
* *

View file

@ -12,6 +12,7 @@ import org.keycloak.models.sessions.infinispan.entities.SessionEntity;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -200,6 +201,38 @@ public class ClientSessionAdapter implements ClientSessionModel {
} }
@Override
public Set<String> getRequiredActions() {
Set<String> 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() { void update() {
provider.getTx().replace(cache, entity.getId(), entity); provider.getTx().replace(cache, entity.getId(), entity);
} }

View file

@ -11,6 +11,7 @@ import org.keycloak.models.sessions.infinispan.compat.entities.UserSessionEntity
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -197,4 +198,35 @@ public class ClientSessionAdapter implements ClientSessionModel {
else entity.setAuthUserId(user.getId()); else entity.setAuthUserId(user.getId());
} }
@Override
public Set<String> getRequiredActions() {
Set<String> 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());
}
} }

View file

@ -3,6 +3,7 @@ package org.keycloak.models.sessions.infinispan.compat.entities;
import org.keycloak.models.ClientSessionModel; import org.keycloak.models.ClientSessionModel;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -28,6 +29,8 @@ public class ClientSessionEntity {
private Set<String> protocolMappers; private Set<String> protocolMappers;
private Map<String, String> notes = new HashMap<>(); private Map<String, String> notes = new HashMap<>();
private Map<String, String> userSessionNotes = new HashMap<>(); private Map<String, String> userSessionNotes = new HashMap<>();
private Set<String> requiredActions = new HashSet<>();
public String getId() { public String getId() {
return id; return id;
@ -133,6 +136,10 @@ public class ClientSessionEntity {
return userSessionNotes; return userSessionNotes;
} }
public Set<String> getRequiredActions() {
return requiredActions;
}
@Override @Override
public boolean equals(Object o) { public boolean equals(Object o) {
if (this == o) return true; if (this == o) return true;

View file

@ -3,6 +3,9 @@ package org.keycloak.models.sessions.infinispan.entities;
import org.keycloak.models.ClientSessionModel; import org.keycloak.models.ClientSessionModel;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -31,6 +34,8 @@ public class ClientSessionEntity extends SessionEntity {
private Map<String, String> userSessionNotes; private Map<String, String> userSessionNotes;
private Map<String, ClientSessionModel.ExecutionStatus> authenticatorStatus = new HashMap<>(); private Map<String, ClientSessionModel.ExecutionStatus> authenticatorStatus = new HashMap<>();
private String authUserId; private String authUserId;
private Set<String> requiredActions = new HashSet<>();
public String getClient() { public String getClient() {
return client; return client;
@ -136,5 +141,7 @@ public class ClientSessionEntity extends SessionEntity {
this.userSessionNotes = userSessionNotes; this.userSessionNotes = userSessionNotes;
} }
public Set<String> getRequiredActions() {
return requiredActions;
}
} }

View file

@ -19,7 +19,7 @@ public class ResetOTP extends AbstractSetRequiredActionAuthenticator {
if (context.getExecution().isRequired() || if (context.getExecution().isRequired() ||
(context.getExecution().isOptional() && (context.getExecution().isOptional() &&
configuredFor(context))) { configuredFor(context))) {
context.getUser().addRequiredAction(UserModel.RequiredAction.CONFIGURE_TOTP); context.getClientSession().addRequiredAction(UserModel.RequiredAction.CONFIGURE_TOTP);
} }
context.success(); context.success();
} }

View file

@ -27,7 +27,7 @@ public class ResetPassword extends AbstractSetRequiredActionAuthenticator {
if (context.getExecution().isRequired() || if (context.getExecution().isRequired() ||
(context.getExecution().isOptional() && (context.getExecution().isOptional() &&
configuredFor(context))) { configuredFor(context))) {
context.getUser().addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD); context.getClientSession().addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD);
} }
context.success(); context.success();
} }

View file

@ -441,32 +441,14 @@ public class AuthenticationManager {
event.detail(Details.CODE_ID, clientSession.getId()); event.detail(Details.CODE_ID, clientSession.getId());
Set<String> requiredActions = user.getRequiredActions(); Set<String> requiredActions = user.getRequiredActions();
for (String action : requiredActions) { Response action = executionActions(session, userSession, clientSession, request, event, realm, user, requiredActions);
RequiredActionProviderModel model = realm.getRequiredActionProviderByAlias(action); if (action != null) return action;
RequiredActionFactory factory = (RequiredActionFactory)session.getKeycloakSessionFactory().getProviderFactory(RequiredActionProvider.class, model.getProviderId());
if (factory == null) { // executionActions() method should remove any duplicate actions that might be in the clientSession
throw new RuntimeException("Unable to find factory for Required Action: " + model.getProviderId() + " did you forget to declare it in a META-INF/services file?"); requiredActions = clientSession.getRequiredActions();
} action = executionActions(session, userSession, clientSession, request, event, realm, user, requiredActions);
RequiredActionProvider actionProvider = factory.create(session); if (action != null) return action;
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();
clientSession.getUserSession().getUser().removeRequiredAction(factory.getId());
}
}
if (client.isConsentRequired()) { if (client.isConsentRequired()) {
UserConsentModel grantedConsent = user.getConsentByClient(client.getId()); 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<String> 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) { 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 // see if any required actions need triggering, i.e. an expired password

View file

@ -684,6 +684,8 @@ public class LoginActionsService {
provider.processAction(context); provider.processAction(context);
if (context.getStatus() == RequiredActionContext.Status.SUCCESS) { if (context.getStatus() == RequiredActionContext.Status.SUCCESS) {
event.clone().success(); event.clone().success();
// do both
clientSession.removeRequiredAction(factory.getId());
clientSession.getUserSession().getUser().removeRequiredAction(factory.getId()); clientSession.getUserSession().getUser().removeRequiredAction(factory.getId());
event.event(EventType.LOGIN); event.event(EventType.LOGIN);
return AuthenticationManager.nextActionAfterAuthentication(session, clientSession.getUserSession(), clientSession, clientConnection, request, uriInfo, event); return AuthenticationManager.nextActionAfterAuthentication(session, clientSession.getUserSession(), clientSession, clientConnection, request, uriInfo, event);

View file

@ -837,7 +837,10 @@ public class UsersResource {
@Path("{id}/execute-actions-email") @Path("{id}/execute-actions-email")
@PUT @PUT
@Consumes(MediaType.APPLICATION_JSON) @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<String> actions) {
auth.requireManage(); auth.requireManage();
UserModel user = session.users().getUserById(id, realm); UserModel user = session.users().getUserById(id, realm);
@ -850,6 +853,9 @@ public class UsersResource {
} }
ClientSessionModel clientSession = createClientSession(user, redirectUri, clientId); ClientSessionModel clientSession = createClientSession(user, redirectUri, clientId);
for (String action : actions) {
clientSession.addRequiredAction(action);
}
ClientSessionCode accessCode = new ClientSessionCode(realm, clientSession); ClientSessionCode accessCode = new ClientSessionCode(realm, clientSession);
accessCode.setAction(ClientSessionModel.Action.EXECUTE_ACTIONS.name()); accessCode.setAction(ClientSessionModel.Action.EXECUTE_ACTIONS.name());

View file

@ -14,6 +14,7 @@ import javax.ws.rs.ClientErrorException;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List; import java.util.List;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@ -359,9 +360,9 @@ public class UserTest extends AbstractClientTest {
String id = ApiUtil.getCreatedId(response); String id = ApiUtil.getCreatedId(response);
response.close(); response.close();
UserResource user = realm.users().get(id); UserResource user = realm.users().get(id);
List<String> actions = new LinkedList<>();
try { try {
user.executeActionsEmail(); user.executeActionsEmail(actions);
fail("Expected failure"); fail("Expected failure");
} catch (ClientErrorException e) { } catch (ClientErrorException e) {
assertEquals(400, e.getResponse().getStatus()); assertEquals(400, e.getResponse().getStatus());
@ -374,7 +375,7 @@ public class UserTest extends AbstractClientTest {
userRep.setEmail("user1@localhost"); userRep.setEmail("user1@localhost");
userRep.setEnabled(false); userRep.setEnabled(false);
user.update(userRep); user.update(userRep);
user.executeActionsEmail(); user.executeActionsEmail(actions);
fail("Expected failure"); fail("Expected failure");
} catch (ClientErrorException e) { } catch (ClientErrorException e) {
assertEquals(400, e.getResponse().getStatus()); assertEquals(400, e.getResponse().getStatus());
@ -385,7 +386,7 @@ public class UserTest extends AbstractClientTest {
try { try {
userRep.setEnabled(true); userRep.setEnabled(true);
user.update(userRep); user.update(userRep);
user.executeActionsEmail("invalidClientId"); user.executeActionsEmail("invalidClientId", actions);
fail("Expected failure"); fail("Expected failure");
} catch (ClientErrorException e) { } catch (ClientErrorException e) {
assertEquals(400, e.getResponse().getStatus()); assertEquals(400, e.getResponse().getStatus());