KEYCLOAK-272 Improved user credential management, including option to send user password reset email from admin console

This commit is contained in:
Stian Thorgersen 2014-02-05 14:34:17 +00:00
parent 457853aa28
commit 722f7c8840
8 changed files with 184 additions and 76 deletions

View file

@ -227,7 +227,7 @@ module.controller('UserDetailCtrl', function($scope, realm, user, User, $locatio
}; };
}); });
module.controller('UserCredentialsCtrl', function($scope, realm, user, User, UserCredentials, Notifications) { module.controller('UserCredentialsCtrl', function($scope, realm, user, User, UserCredentials, Notifications, Dialog) {
console.log('UserCredentialsCtrl'); console.log('UserCredentialsCtrl');
$scope.realm = realm; $scope.realm = realm;
@ -239,56 +239,45 @@ module.controller('UserCredentialsCtrl', function($scope, realm, user, User, Use
} }
$scope.resetPassword = function() { $scope.resetPassword = function() {
if ($scope.pwdChange) { if ($scope.pwdChange) {
if ($scope.password != $scope.confirmPassword) { if ($scope.password != $scope.confirmPassword) {
Notifications.error("Password and confirmation does not match."); Notifications.error("Password and confirmation does not match.");
$scope.password = "";
$scope.confirmPassword = "";
return; return;
} }
if (!$scope.user.hasOwnProperty('requiredActions')){
$scope.user.requiredActions = [];
}
if ($scope.user.requiredActions.indexOf("UPDATE_PASSWORD") < 0){
$scope.user.requiredActions.push("UPDATE_PASSWORD");
}
} }
var credentials = [ { type : "password", value : $scope.password } ]; Dialog.confirm('Reset password', 'Are you sure you want to reset the users password?', function() {
UserCredentials.resetPassword({ realm: realm.realm, userId: user.username }, { type : "password", value : $scope.password }, function() {
Notifications.success("The password has been reset");
$scope.password = null;
$scope.confirmPassword = null;
}, function() {
Notifications.error("Failed to reset user password");
});
}, function() {
$scope.password = null;
$scope.confirmPassword = null;
});
};
User.update({ $scope.removeTotp = function() {
realm: realm.realm, Dialog.confirm('Remove totp', 'Are you sure you want to remove the users totp configuration?', function() {
userId: $scope.user.username UserCredentials.removeTotp({ realm: realm.realm, userId: user.username }, { }, function() {
}, $scope.user, function () { Notifications.success("The users totp configuration has been removed");
$scope.user.totp = false;
}, function() {
Notifications.error("Failed to remove the users totp configuration");
});
});
};
$scope.isTotp = $scope.user.totp; $scope.resetPasswordEmail = function() {
Dialog.confirm('Reset password email', 'Are you sure you want to send password reset email to user?', function() {
if ($scope.pwdChange){ UserCredentials.resetPasswordEmail({ realm: realm.realm, userId: user.username }, { }, function() {
UserCredentials.update({ Notifications.success("Password reset email sent to user");
realm: realm.realm, }, function() {
userId: $scope.user.username Notifications.error("Failed to send password reset mail to user");
}, credentials, function () { });
Notifications.success("The password has been reset. The user is required to change his password on" +
" the next login.");
$scope.password = "";
$scope.confirmPassword = "";
$scope.pwdChange = false;
$scope.isTotp = user.totp;
$scope.userChange = false;
}, function () {
Notifications.error("Error while resetting user password. Be aware that the update password required action" +
" was already set.");
});
} else {
Notifications.success("User settings was updated.");
$scope.isTotp = user.totp;
$scope.userChange = false;
}
}, function () {
Notifications.error("Error while updating user settings.");
}); });
}; };

View file

@ -51,6 +51,28 @@ module.service('Dialog', function($dialog) {
}); });
} }
dialog.confirm = function(title, message, success, cancel) {
var title = title;
var msg = '<span class="primary">' + message + '"</span>' +
'<span>This action can\'t be undone.</span>';
var btns = [ {
result : 'cancel',
label : 'Cancel'
}, {
result : 'ok',
label : title,
cssClass : 'destructive'
} ];
$dialog.messageBox(title, msg, btns).open().then(function(result) {
if (result == "ok") {
success();
} else {
cancel && cancel();
}
});
}
return dialog return dialog
}); });
@ -136,15 +158,36 @@ module.factory('User', function($resource) {
}); });
module.factory('UserCredentials', function($resource) { module.factory('UserCredentials', function($resource) {
return $resource('/auth/rest/admin/realms/:realm/users/:userId/credentials', { var credentials = {};
credentials.resetPassword = $resource('/auth/rest/admin/realms/:realm/users/:userId/reset-password', {
realm : '@realm', realm : '@realm',
userId : '@userId' userId : '@userId'
}, { }, {
update : { update : {
method : 'PUT', method : 'PUT'
isArray : true
} }
}); }).update;
credentials.removeTotp = $resource('/auth/rest/admin/realms/:realm/users/:userId/remove-totp', {
realm : '@realm',
userId : '@userId'
}, {
update : {
method : 'PUT'
}
}).update;
credentials.resetPasswordEmail = $resource('/auth/rest/admin/realms/:realm/users/:userId/reset-password-email', {
realm : '@realm',
userId : '@userId'
}, {
update : {
method : 'PUT'
}
}).update;
return credentials;
}); });
module.factory('RealmRoleMapping', function($resource) { module.factory('RealmRoleMapping', function($resource) {

View file

@ -18,32 +18,30 @@
<form name="userForm" novalidate> <form name="userForm" novalidate>
<fieldset class="border-top"> <fieldset class="border-top">
<legend uncollapsed><span class="text">Reset Password</span></legend> <legend uncollapsed><span class="text">Credential Management</span></legend>
<div class="form-group"> <div class="form-group">
<label for="password">New Password</label> <label for="password">Reset password</label>
<div class="controls"> <div class="controls">
<input type="password" id="password" name="password" data-ng-model="password" autofocus <input type="password" id="password" name="password" data-ng-model="password" placeholder="Temporary password" required>
required> <input type="password" id="confirmPassword" name="confirmPassword" data-ng-model="confirmPassword" placeholder="Password confirmation" required>
<button type="submit" data-ng-click="resetPassword()" class="destructive" data-ng-show="password">Reset Password</button>
</div> </div>
</div> </div>
<div class="form-group">
<label class="two-lines" for="password">New Password Confirmation</label> <div class="form-group" data-ng-show="user.email">
<label for="password">Reset password email</label>
<div class="controls"> <div class="controls">
<input type="password" id="confirmPassword" name="confirmPassword" <button type="submit" data-ng-click="resetPasswordEmail()" class="destructive">Send Email</button>
data-ng-model="confirmPassword" required>
</div> </div>
</div> </div>
<div class="form-group clearfix block" >
<label for="userTotp" class="control-label">TOTP Enabled</label> <div class="form-group" data-ng-show="user.totp">
<input ng-model="user.totp" name="userTotp" class="kokosak" ng-disabled="!isTotp" id="userTotp" onoffswitch/> <label for="password">Remove totp</label>
<div class="controls" data-ng-show="user.totp">
<button type="submit" data-ng-click="removeTotp()" class="destructive">Remove TOTP</button>
</div>
</div> </div>
</fieldset> </fieldset>
<div class="form-actions">
<button type="submit" data-ng-click="resetPassword()" class="primary" data-ng-show="userChange && !pwdChange">Save</button>
<button type="submit" data-ng-click="resetPassword()" class="primary" data-ng-show="!userChange && pwdChange">Reset Password</button>
<button type="submit" data-ng-click="resetPassword()" class="primary" data-ng-show="userChange && pwdChange">Save and Reset Password</button>
<button type="submit" kc-reset data-ng-show="userChange || pwdChange">Clear changes</button>
</div>
</form> </form>
</div> </div>

View file

@ -192,7 +192,7 @@ public class AdminService {
logger.warn("not a Realm admin"); logger.warn("not a Realm admin");
throw new NotAuthorizedException("Bearer"); throw new NotAuthorizedException("Bearer");
} }
RealmsAdminResource adminResource = new RealmsAdminResource(admin); RealmsAdminResource adminResource = new RealmsAdminResource(admin, tokenManager);
resourceContext.initResource(adminResource); resourceContext.initResource(adminResource);
return adminResource; return adminResource;
} }

View file

@ -8,6 +8,7 @@ import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.services.managers.ModelToRepresentation; import org.keycloak.services.managers.ModelToRepresentation;
import org.keycloak.services.managers.RealmManager; import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.managers.TokenManager;
import javax.ws.rs.*; import javax.ws.rs.*;
import javax.ws.rs.container.ResourceContext; import javax.ws.rs.container.ResourceContext;
@ -21,6 +22,7 @@ public class RealmAdminResource extends RoleContainerResource {
protected static final Logger logger = Logger.getLogger(RealmAdminResource.class); protected static final Logger logger = Logger.getLogger(RealmAdminResource.class);
protected UserModel admin; protected UserModel admin;
protected RealmModel realm; protected RealmModel realm;
private TokenManager tokenManager;
@Context @Context
protected ResourceContext resourceContext; protected ResourceContext resourceContext;
@ -28,10 +30,11 @@ public class RealmAdminResource extends RoleContainerResource {
@Context @Context
protected KeycloakSession session; protected KeycloakSession session;
public RealmAdminResource(UserModel admin, RealmModel realm) { public RealmAdminResource(UserModel admin, RealmModel realm, TokenManager tokenManager) {
super(realm, realm); super(realm, realm);
this.admin = admin; this.admin = admin;
this.realm = realm; this.realm = realm;
this.tokenManager = tokenManager;
} }
@Path("applications") @Path("applications")
@ -72,7 +75,7 @@ public class RealmAdminResource extends RoleContainerResource {
@Path("users") @Path("users")
public UsersResource users() { public UsersResource users() {
UsersResource users = new UsersResource(realm); UsersResource users = new UsersResource(realm, tokenManager);
resourceContext.initResource(users); resourceContext.initResource(users);
return users; return users;
} }

View file

@ -11,6 +11,7 @@ import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.services.managers.ModelToRepresentation; import org.keycloak.services.managers.ModelToRepresentation;
import org.keycloak.services.managers.RealmManager; import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.managers.TokenManager;
import org.keycloak.services.resources.flows.Flows; import org.keycloak.services.resources.flows.Flows;
import javax.ws.rs.*; import javax.ws.rs.*;
@ -35,9 +36,11 @@ import java.util.Map;
public class RealmsAdminResource { public class RealmsAdminResource {
protected static final Logger logger = Logger.getLogger(RealmsAdminResource.class); protected static final Logger logger = Logger.getLogger(RealmsAdminResource.class);
protected UserModel admin; protected UserModel admin;
protected TokenManager tokenManager;
public RealmsAdminResource(UserModel admin) { public RealmsAdminResource(UserModel admin, TokenManager tokenManager) {
this.admin = admin; this.admin = admin;
this.tokenManager = tokenManager;
} }
public static final CacheControl noCache = new CacheControl(); public static final CacheControl noCache = new CacheControl();
@ -110,7 +113,7 @@ public class RealmsAdminResource {
RealmModel realm = realmManager.getRealmByName(name); RealmModel realm = realmManager.getRealmByName(name);
if (realm == null) throw new NotFoundException("{realm} = " + name); if (realm == null) throw new NotFoundException("{realm} = " + name);
RealmAdminResource adminResource = new RealmAdminResource(admin, realm); RealmAdminResource adminResource = new RealmAdminResource(admin, realm, tokenManager);
resourceContext.initResource(adminResource); resourceContext.initResource(adminResource);
return adminResource; return adminResource;
} }

View file

@ -10,10 +10,16 @@ import org.keycloak.models.RoleModel;
import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.*; import org.keycloak.representations.idm.*;
import org.keycloak.services.email.EmailException;
import org.keycloak.services.email.EmailSender;
import org.keycloak.services.managers.AccessCodeEntry;
import org.keycloak.services.managers.ModelToRepresentation; import org.keycloak.services.managers.ModelToRepresentation;
import org.keycloak.services.managers.RealmManager; import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.managers.TokenManager;
import org.keycloak.services.resources.flows.Flows; import org.keycloak.services.resources.flows.Flows;
import org.keycloak.services.resources.flows.Urls;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE; import javax.ws.rs.DELETE;
import javax.ws.rs.GET; import javax.ws.rs.GET;
@ -25,12 +31,15 @@ import javax.ws.rs.Path;
import javax.ws.rs.PathParam; import javax.ws.rs.PathParam;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam; import javax.ws.rs.QueryParam;
import javax.ws.rs.ServerErrorException;
import javax.ws.rs.container.ResourceContext; import javax.ws.rs.container.ResourceContext;
import javax.ws.rs.core.Context; import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo; import javax.ws.rs.core.UriInfo;
import java.net.URI;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -44,10 +53,16 @@ public class UsersResource {
protected RealmModel realm; protected RealmModel realm;
public UsersResource(RealmModel realm) { private TokenManager tokenManager;
public UsersResource(RealmModel realm, TokenManager tokenManager) {
this.realm = realm; this.realm = realm;
this.tokenManager = tokenManager;
} }
@Context
protected UriInfo uriInfo;
@Context @Context
protected ResourceContext resourceContext; protected ResourceContext resourceContext;
@ -373,21 +388,72 @@ public class UsersResource {
} }
} }
@Path("{username}/credentials") @Path("{username}/reset-password")
@PUT @PUT
@Consumes("application/json") @Consumes("application/json")
public void updateCredentials(@PathParam("username") String username, List<CredentialRepresentation> credentials) { public void resetPassword(@PathParam("username") String username, CredentialRepresentation pass) {
UserModel user = realm.getUser(username); UserModel user = realm.getUser(username);
if (user == null) { if (user == null) {
throw new NotFoundException(); throw new NotFoundException();
} }
if (credentials == null) return; if (pass == null || pass.getValue() == null || !CredentialRepresentation.PASSWORD.equals(pass.getType())) {
throw new BadRequestException();
for (CredentialRepresentation rep : credentials) {
UserCredentialModel cred = RealmManager.fromRepresentation(rep);
realm.updateCredential(user, cred);
} }
UserCredentialModel cred = RealmManager.fromRepresentation(pass);
realm.updateCredential(user, cred);
user.addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD);
}
@Path("{username}/remove-totp")
@PUT
@Consumes("application/json")
public void removeTotp(@PathParam("username") String username) {
UserModel user = realm.getUser(username);
if (user == null) {
throw new NotFoundException();
}
user.setTotp(false);
}
@Path("{username}/reset-password-email")
@PUT
@Consumes("application/json")
public Response resetPasswordEmail(@PathParam("username") String username) {
UserModel user = realm.getUser(username);
if (user == null) {
throw new NotFoundException();
}
if (user.getEmail() == null) {
return Flows.errors().error("User email missing", Response.Status.BAD_REQUEST);
}
String redirect = Urls.accountBase(uriInfo.getBaseUri()).path("/").build(realm.getName()).toString();
String clientId = Constants.ACCOUNT_APPLICATION;
String state = null;
String scope = null;
UserModel client = realm.getUser(clientId);
if (client == null || !client.isEnabled()) {
return Flows.errors().error("Account management not enabled", Response.Status.INTERNAL_SERVER_ERROR);
}
Set<UserModel.RequiredAction> requiredActions = new HashSet<UserModel.RequiredAction>(user.getRequiredActions());
requiredActions.add(UserModel.RequiredAction.UPDATE_PASSWORD);
AccessCodeEntry accessCode = tokenManager.createAccessCode(scope, state, redirect, realm, client, user);
accessCode.setRequiredActions(requiredActions);
accessCode.setExpiration(System.currentTimeMillis() / 1000 + realm.getAccessCodeLifespanUserAction());
try {
new EmailSender(realm.getSmtpConfig()).sendPasswordReset(user, realm, accessCode, uriInfo);
return Response.ok().build();
} catch (EmailException e) {
logger.error("Failed to send password reset email", e);
return Flows.errors().error("Failed to send email", Response.Status.INTERNAL_SERVER_ERROR);
}
} }
} }

View file

@ -16,4 +16,10 @@ public class ErrorFlows {
return Response.status(Response.Status.CONFLICT).entity(error).type(MediaType.APPLICATION_JSON).build(); return Response.status(Response.Status.CONFLICT).entity(error).type(MediaType.APPLICATION_JSON).build();
} }
public Response error(String message, Response.Status status) {
ErrorRepresentation error = new ErrorRepresentation();
error.setErrorMessage(message);
return Response.status(status).entity(error).type(MediaType.APPLICATION_JSON).build();
}
} }