KEYCLOAK-953: add allowing user to delete his own account feature

This commit is contained in:
zak905 2019-08-15 20:06:44 +02:00 committed by Stian Thorgersen
parent e56bd9d8b8
commit 4f330f4a57
27 changed files with 727 additions and 10 deletions

View file

@ -38,6 +38,7 @@ public interface Errors {
String USER_TEMPORARILY_DISABLED = "user_temporarily_disabled";
String INVALID_USER_CREDENTIALS = "invalid_user_credentials";
String DIFFERENT_USER_AUTHENTICATED = "different_user_authenticated";
String USER_DELETE_ERROR = "user_delete_error";
String USERNAME_MISSING = "username_missing";
String USERNAME_IN_USE = "username_in_use";

View file

@ -132,7 +132,10 @@ public enum EventType {
TOKEN_EXCHANGE_ERROR(true),
PERMISSION_TOKEN(true),
PERMISSION_TOKEN_ERROR(false);
PERMISSION_TOKEN_ERROR(false),
DELETE_ACCOUNT(true),
DELETE_ACCOUNT_ERROR(true);
private boolean saveByDefault;

View file

@ -22,6 +22,7 @@ import java.util.Map;
import java.util.regex.Pattern;
import org.jboss.logging.Logger;
import org.keycloak.common.Version;
import org.keycloak.migration.migrators.MigrateTo12_0_0;
import org.keycloak.migration.migrators.MigrateTo1_2_0;
import org.keycloak.migration.migrators.MigrateTo1_3_0;
import org.keycloak.migration.migrators.MigrateTo1_4_0;
@ -90,7 +91,8 @@ public class MigrationModelManager {
new MigrateTo8_0_0(),
new MigrateTo8_0_2(),
new MigrateTo9_0_0(),
new MigrateTo9_0_4()
new MigrateTo9_0_4(),
new MigrateTo12_0_0()
};
public static void migrate(KeycloakSession session) {

View file

@ -0,0 +1,43 @@
package org.keycloak.migration.migrators;
import java.util.Objects;
import org.keycloak.migration.ModelVersion;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RequiredActionProviderModel;
public class MigrateTo12_0_0 implements Migration {
public static final ModelVersion VERSION = new ModelVersion("12.0.0");
public static final RequiredActionProviderModel deleteAccount = new RequiredActionProviderModel();
static {
deleteAccount.setEnabled(false);
deleteAccount.setAlias("delete_account");
deleteAccount.setName("Delete Account");
deleteAccount.setProviderId("delete_account");
deleteAccount.setDefaultAction(false);
deleteAccount.setPriority(60);
}
@Override
public void migrate(KeycloakSession session) {
session.realms()
.getRealms()
.stream()
.map(realm -> realm.getClientByClientId("account"))
.filter(client -> Objects.isNull(client.getRole(AccountRoles.DELETE_ACCOUNT)))
.forEach(client -> client.addRole(AccountRoles.DELETE_ACCOUNT)
.setDescription("${role_"+AccountRoles.DELETE_ACCOUNT+"}"));
session.realms().getRealms().stream().filter(realm -> Objects.isNull(realm.getRequiredActionProviderByAlias("delete_account"))).forEach(realm -> realm.addRequiredActionProvider(deleteAccount));
}
@Override
public ModelVersion getVersion() {
return VERSION;
}
}

View file

@ -28,6 +28,7 @@ public interface AccountRoles {
String VIEW_APPLICATIONS = "view-applications";
String VIEW_CONSENT = "view-consent";
String MANAGE_CONSENT = "manage-consent";
String DELETE_ACCOUNT = "delete-account";
String[] ALL = {VIEW_PROFILE, MANAGE_ACCOUNT};

View file

@ -84,6 +84,17 @@ public class DefaultRequiredActions {
}
addUpdateLocaleAction(realm);
if (realm.getRequiredActionProviderByAlias("delete_account") == null) {
RequiredActionProviderModel deleteAccount = new RequiredActionProviderModel();
deleteAccount.setEnabled(false);
deleteAccount.setAlias("delete_account");
deleteAccount.setName("Delete Account");
deleteAccount.setProviderId("delete_account");
deleteAccount.setDefaultAction(false);
deleteAccount.setPriority(60);
realm.addRequiredActionProvider(deleteAccount);
}
}
public static void addUpdateLocaleAction(RealmModel realm) {

View file

@ -0,0 +1,187 @@
/*
* Copyright 2016 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.requiredactions;
import java.util.Objects;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.authentication.InitiatedActionSupport;
import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserManager;
import org.keycloak.models.UserModel;
import org.keycloak.services.ForbiddenException;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.messages.Messages;
public class DeleteAccount implements RequiredActionProvider, RequiredActionFactory {
public static final String PROVIDER_ID = "delete_account";
private static final String TRIGGERED_FROM_AIA = "triggered_from_aia";
private static final Logger logger = Logger.getLogger(DeleteAccount.class);
@Override
public String getDisplayText() {
return "Delete Account";
}
@Override
public void evaluateTriggers(RequiredActionContext context) {
}
@Override
public void requiredActionChallenge(RequiredActionContext context) {
if (!clientHasDeleteAccountRole(context)) {
context.challenge(context.form().setError(Messages.DELETE_ACCOUNT_LACK_PRIVILEDGES).createForm("error.ftl"));
return;
}
context.challenge(context.form().setAttribute(TRIGGERED_FROM_AIA, isCurrentActionTriggeredFromAIA(context)).createForm("delete-account-confirm.ftl"));
}
@Override
public void processAction(RequiredActionContext context) {
KeycloakSession session = context.getSession();
EventBuilder eventBuilder = context.getEvent();
KeycloakContext keycloakContext = session.getContext();
RealmModel realm = keycloakContext.getRealm();
UserModel user = keycloakContext.getAuthenticationSession().getAuthenticatedUser();
try {
if(!clientHasDeleteAccountRole(context)) {
throw new ForbiddenException();
}
boolean removed = new UserManager(session).removeUser(realm, user);
if (removed) {
eventBuilder.event(EventType.DELETE_ACCOUNT)
.client(keycloakContext.getClient())
.user(user)
.detail(Details.USERNAME, user.getUsername())
.success();
cleanSession(context, RequiredActionContext.KcActionStatus.SUCCESS);
context.challenge(context.form()
.setAttribute("messageHeader", "")
.setInfo("userDeletedSuccessfully")
.createForm("info.ftl"));
} else {
eventBuilder.event(EventType.DELETE_ACCOUNT)
.client(keycloakContext.getClient())
.user(user)
.detail(Details.USERNAME, user.getUsername())
.error("User could not be deleted");
cleanSession(context, RequiredActionContext.KcActionStatus.ERROR);
context.failure();
}
} catch (ForbiddenException forbidden) {
logger.error("account client does not have the required roles for user deletion");
eventBuilder.event(EventType.DELETE_ACCOUNT_ERROR)
.client(keycloakContext.getClient())
.user(keycloakContext.getAuthenticationSession().getAuthenticatedUser())
.detail(Details.REASON, "does not have the required roles for user deletion")
.error(Errors.USER_DELETE_ERROR);
//deletingAccountForbidden
context.challenge(context.form().setAttribute(TRIGGERED_FROM_AIA, isCurrentActionTriggeredFromAIA(context)).setError(Messages.DELETE_ACCOUNT_LACK_PRIVILEDGES).createForm("delete-account-confirm.ftl"));
} catch (Exception exception) {
logger.error("unexpected error happened during account deletion", exception);
eventBuilder.event(EventType.DELETE_ACCOUNT_ERROR)
.client(keycloakContext.getClient())
.user(keycloakContext.getAuthenticationSession().getAuthenticatedUser())
.detail(Details.REASON, exception.getMessage())
.error(Errors.USER_DELETE_ERROR);
context.challenge(context.form().setError(Messages.DELETE_ACCOUNT_ERROR).createForm("delete-account-confirm.ftl"));
}
}
private void cleanSession(RequiredActionContext context, RequiredActionContext.KcActionStatus status) {
context.getAuthenticationSession().removeRequiredAction(PROVIDER_ID);
context.getAuthenticationSession().removeAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION);
AuthenticationManager.setKcActionStatus(PROVIDER_ID, status, context.getAuthenticationSession());
}
private boolean clientHasDeleteAccountRole(RequiredActionContext context) {
RoleModel deleteAccountRole = context.getRealm().getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).getRole(AccountRoles.DELETE_ACCOUNT);
return deleteAccountRole != null && context.getUser().hasRole(deleteAccountRole);
}
private boolean isCurrentActionTriggeredFromAIA(RequiredActionContext context) {
return Objects.equals(context.getAuthenticationSession().getClientNote(Constants.KC_ACTION), PROVIDER_ID);
}
@Override
public RequiredActionProvider create(KeycloakSession session) {
return this;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public InitiatedActionSupport initiatedActionSupport() {
return InitiatedActionSupport.SUPPORTED;
}
@Override
public boolean isOneTimeAction() {
return true;
}
@Override
public int getMaxAuthAge() {
return 0;
}
}

View file

@ -443,6 +443,9 @@ public class RealmManager {
manageConsentRole.setDescription("${role_" + AccountRoles.MANAGE_CONSENT + "}");
manageConsentRole.addCompositeRole(viewConsentRole);
RoleModel deleteOwnAccount = accountClient.addRole(AccountRoles.DELETE_ACCOUNT);
deleteOwnAccount.setDescription("${role_"+AccountRoles.DELETE_ACCOUNT+"}");
ClientModel accountConsoleClient = realm.getClientByClientId(Constants.ACCOUNT_CONSOLE_CLIENT_ID);
if (accountConsoleClient == null) {
accountConsoleClient = KeycloakModelUtils.createClient(realm, Constants.ACCOUNT_CONSOLE_CLIENT_ID);

View file

@ -254,4 +254,7 @@ public class Messages {
public static final String WEBAUTHN_ERROR_AUTH_VERIFICATION = "webauthn-error-auth-verification";
public static final String WEBAUTHN_ERROR_REGISTER_VERIFICATION = "webauthn-error-register-verification";
public static final String WEBAUTHN_ERROR_USER_NOT_FOUND = "webauthn-error-user-not-found";
public static final String DELETE_ACCOUNT_LACK_PRIVILEDGES = "deletingAccountForbidden";
public static final String DELETE_ACCOUNT_ERROR = "errorDeletingAccount";
}

View file

@ -2,12 +2,15 @@ package org.keycloak.services.resources.account;
import org.jboss.logging.Logger;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.keycloak.authentication.requiredactions.DeleteAccount;
import org.keycloak.common.Version;
import org.keycloak.events.EventStoreProvider;
import org.keycloak.models.AccountRoles;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.services.Urls;
@ -124,11 +127,17 @@ public class AccountConsole {
map.put("isAuthorizationEnabled", true);
boolean isTotpConfigured = false;
boolean deleteAccountAllowed = false;
if (user != null) {
isTotpConfigured = session.userCredentialManager().isConfiguredFor(realm, user, realm.getOTPPolicy().getType());
RoleModel deleteAccountRole = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).getRole(AccountRoles.DELETE_ACCOUNT);
deleteAccountAllowed = deleteAccountRole != null && user.hasRole(deleteAccountRole) && realm.getRequiredActionProviderByAlias(DeleteAccount.PROVIDER_ID).isEnabled();
}
map.put("isTotpConfigured", isTotpConfigured);
map.put("deleteAccountAllowed", deleteAccountAllowed);
FreeMarkerUtil freeMarkerUtil = new FreeMarkerUtil();
String result = freeMarkerUtil.processTemplate(map, "index.ftl", theme);
Response.ResponseBuilder builder = Response.status(Response.Status.OK).type(MediaType.TEXT_HTML_UTF_8).language(Locale.ENGLISH).entity(result);

View file

@ -22,4 +22,5 @@ org.keycloak.authentication.requiredactions.VerifyEmail
org.keycloak.authentication.requiredactions.TermsAndConditions
org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory
org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory
org.keycloak.authentication.requiredactions.UpdateUserLocaleAction
org.keycloak.authentication.requiredactions.UpdateUserLocaleAction
org.keycloak.authentication.requiredactions.DeleteAccount

View file

@ -0,0 +1,45 @@
package org.keycloak.testsuite.auth.page.login;
import org.keycloak.authentication.requiredactions.DeleteAccount;
import org.keycloak.testsuite.auth.page.AuthRealm;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import static org.keycloak.testsuite.util.UIUtils.clickLink;
public class DeleteAccountActionConfirmPage extends RequiredActions {
@FindBy(css = "button[name='cancel-aia']")
WebElement cancelActionButton;
@FindBy(css = "input[type='submit']")
WebElement confirmActionButton;
@Override
public String getActionId() {
return DeleteAccount.PROVIDER_ID;
}
@Override
public boolean isCurrent() {
return driver.getCurrentUrl().contains("login-actions/required-action") && driver.getCurrentUrl().contains("execution=delete_account");
}
public void clickCancelAIA() {
clickLink(cancelActionButton);
}
public void clickConfirmAction() {
clickLink(confirmActionButton);
}
public boolean isErrorMessageDisplayed() {
return driver.findElements(By.cssSelector(".alert-error")).size() == 1;
}
public String getErrorMessageText() {
return driver.findElement(By.cssSelector("#kc-content-wrapper > div > span.kc-feedback-text")).getText();
}
}

View file

@ -0,0 +1,105 @@
package org.keycloak.testsuite.actions;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.authentication.requiredactions.DeleteAccount;
import org.keycloak.events.EventType;
import org.keycloak.models.AccountRoles;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.auth.page.login.DeleteAccountActionConfirmPage;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.util.UserBuilder;
public class DeleteAccountActionTest extends AbstractTestRealmKeycloakTest {
@Rule
public AssertEvents events = new AssertEvents(this);
@Page
public DeleteAccountActionConfirmPage deleteAccountPage;
@Page
protected LoginPage loginPage;
@Page
protected ErrorPage errorPage;
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
}
@Before
public void setUpAction() {
UserRepresentation user = ActionUtil.findUserWithAdminClient(adminClient, "test-user@localhost");
UserBuilder.edit(user).requiredAction(DeleteAccount.PROVIDER_ID);
testRealm().users().get(user.getId()).update(user);
addDeleteAccountRoleToUserClientRoles();
RequiredActionProviderRepresentation rep = testRealm().flows().getRequiredAction(DeleteAccount.PROVIDER_ID);
rep.setEnabled(true);
adminClient.realm("test").flows().updateRequiredAction(DeleteAccount.PROVIDER_ID, rep);
}
@Test
public void deleteAccountActionSucceeds() {
loginPage.open();
loginPage.login("test-user@localhost", "password");
Assert.assertTrue(deleteAccountPage.isCurrent());
deleteAccountPage.clickConfirmAction();
events.expect(EventType.DELETE_ACCOUNT);
List<UserRepresentation> users = testRealm().users().search("test-user@localhost");
Assert.assertEquals(users.size(), 0);
}
@Test
public void deleteAccountFailsWithoutRoleFails() {
removeDeleteAccountRoleFromUserClientRoles();
loginPage.open();
loginPage.login("test-user@localhost", "password");
Assert.assertTrue(errorPage.isCurrent());
Assert.assertEquals(errorPage.getError(), "You do not have enough permissions to delete your own account, contact admin.");
}
private void addDeleteAccountRoleToUserClientRoles() {
UserRepresentation user = ActionUtil.findUserWithAdminClient(adminClient, "test-user@localhost");
ApiUtil.assignClientRoles(adminClient.realm("test"), user.getId(), "account", AccountRoles.DELETE_ACCOUNT);
}
private void removeDeleteAccountRoleFromUserClientRoles() {
UserRepresentation user = ActionUtil.findUserWithAdminClient(adminClient, "test-user@localhost");
UserResource userResource = testRealm().users().get(user.getId());
ClientRepresentation clientRepresentation = testRealm().clients().findByClientId("account").get(0);
String deleteRoleId = userResource.roles().clientLevel(clientRepresentation.getId()).listAll().stream().filter(role -> Objects
.equals(role.getName(), "delete-account")).findFirst().get().getId();
RoleRepresentation deleteRole = new RoleRepresentation();
deleteRole.setName("delete-account");
deleteRole.setId(deleteRoleId);
userResource.roles().clientLevel(clientRepresentation.getId()).remove(Arrays.asList(deleteRole));
}
}

View file

@ -560,8 +560,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.MANAGE_ACCOUNT, AccountRoles.MANAGE_ACCOUNT_LINKS,
AccountRoles.VIEW_APPLICATIONS, AccountRoles.VIEW_CONSENT, AccountRoles.MANAGE_CONSENT);
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listAvailable(), AccountRoles.MANAGE_ACCOUNT, AccountRoles.MANAGE_ACCOUNT_LINKS, AccountRoles.VIEW_APPLICATIONS, AccountRoles.VIEW_CONSENT, AccountRoles.MANAGE_CONSENT, AccountRoles.DELETE_ACCOUNT);
Assert.assertNames(scopesResource.getAll().getRealmMappings(), "role1");
Assert.assertNames(scopesResource.getAll().getClientMappings().get(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).getMappings(), AccountRoles.VIEW_PROFILE);
@ -576,8 +575,9 @@ 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.VIEW_PROFILE, AccountRoles.MANAGE_ACCOUNT, AccountRoles.MANAGE_ACCOUNT_LINKS,
AccountRoles.VIEW_APPLICATIONS, AccountRoles.VIEW_CONSENT, AccountRoles.MANAGE_CONSENT);
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listAvailable(), AccountRoles.VIEW_PROFILE, AccountRoles.MANAGE_ACCOUNT, AccountRoles.MANAGE_ACCOUNT_LINKS, AccountRoles.VIEW_APPLICATIONS, AccountRoles.VIEW_CONSENT, AccountRoles.MANAGE_CONSENT, AccountRoles.DELETE_ACCOUNT);
Assert.assertNames(scopesResource.clientLevel(accountMgmtId).listEffective());
}

View file

@ -51,6 +51,7 @@ public class RequiredActionsTest extends AbstractAuthenticationTest {
addRequiredAction(expected, "UPDATE_PASSWORD", "Update Password", true, false, null);
addRequiredAction(expected, "UPDATE_PROFILE", "Update Profile", true, false, null);
addRequiredAction(expected, "VERIFY_EMAIL", "Verify Email", true, false, null);
addRequiredAction(expected, "delete_account", "Delete Account", false, false, null);
addRequiredAction(expected, "terms_and_conditions", "Terms and Conditions", false, false, null);
addRequiredAction(expected, "update_user_locale", "Update User Locale", true, false, null);

View file

@ -285,6 +285,19 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest {
testUserLocaleActionAdded(migrationRealm);
}
protected void testMigrationTo12_0_0() {
testAccountConsoleClientHasDeleteUserRole(masterRealm);
testAccountConsoleClientHasDeleteUserRole(migrationRealm);
}
private void testAccountConsoleClientHasDeleteUserRole(RealmResource realm) {
ClientRepresentation accountClient = realm.clients().findByClientId(ACCOUNT_MANAGEMENT_CLIENT_ID).get(0);
ClientResource accountResource = realm.clients().get(accountClient.getId());
RoleRepresentation deleteUserRole = accountResource.roles().get(AccountRoles.DELETE_ACCOUNT).toRepresentation();
assertNotNull(deleteUserRole);
}
private void testAccountClient(RealmResource realm) {
ClientRepresentation accountClient = realm.clients().findByClientId(ACCOUNT_MANAGEMENT_CLIENT_ID).get(0);
@ -890,6 +903,10 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest {
testMigrationTo9_0_0();
}
protected void testMigrationTo12_x() {
testMigrationTo12_0_0();
}
protected void testMigrationTo7_x(boolean supportedAuthzServices) {
if (supportedAuthzServices) {
testDecisionStrategySetOnResourceServer();

View file

@ -57,6 +57,12 @@ public class MigrationTest extends AbstractMigrationTest {
}
}
@Test
@Migration(versionFrom = "12.")
public void migration12_xTest() {
testMigrationTo12_x();
}
@Test
@Migration(versionFrom = "9.")
public void migration9_xTest() throws Exception {

View file

@ -0,0 +1,150 @@
/*
* 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.testsuite.ui.account2;
import java.util.Arrays;
import java.util.Objects;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.events.EventType;
import org.keycloak.models.AccountRoles;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RequiredActionProviderRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.ui.account2.page.AbstractLoggedInPage;
import org.keycloak.testsuite.auth.page.login.DeleteAccountActionConfirmPage;
import org.keycloak.testsuite.ui.account2.page.PersonalInfoPage;
import static org.keycloak.testsuite.util.UIUtils.refreshPageAndWaitForLoad;
/**
* @author Zakaria Amine <zakaria.amine88@gmail.com>
*/
public class DeleteAccountTest extends BaseAccountPageTest {
@Page
private PersonalInfoPage personalInfoPage;
@Page
private DeleteAccountActionConfirmPage deleteAccountActionConfirmPage;
@Rule
public AssertEvents events = new AssertEvents(this);
@Override
protected AbstractLoggedInPage getAccountPage() {
return personalInfoPage;
}
@Before
public void setup() {
enableDeleteAccountRequiredAction();
addDeleteAccountRoleToUserClientRoles();
}
@After
public void clean() {
disableDeleteAccountRequiredAction();
}
@Test
public void deleteOwnAccountSectionNotVisibleWithoutClientRole() {
removeDeleteAccountRoleFromUserClientRoles();
refreshPageAndWaitForLoad();
personalInfoPage.assertDeleteAccountSectionVisible(false);
}
@Test
public void deleteOwnAccountSectionNotVisibleWithoutDeleteAccountActionEnabled() {
disableDeleteAccountRequiredAction();
refreshPageAndWaitForLoad();
personalInfoPage.assertDeleteAccountSectionVisible(false);
}
@Test
public void deleteOwnAccountAIACancellationSucceeds() {
refreshPageAndWaitForLoad();
personalInfoPage.assertDeleteAccountSectionVisible(true);
personalInfoPage.clickOpenDeleteExapandable();
personalInfoPage.clickDeleteAccountButton();
loginPage.form().login(testUser);
Assert.assertTrue(deleteAccountActionConfirmPage.isCurrent());
deleteAccountActionConfirmPage.clickCancelAIA();
Assert.assertTrue(personalInfoPage.isCurrent());
}
@Test
public void deleteOwnAccountForbiddenWithoutClientRole() {
refreshPageAndWaitForLoad();
personalInfoPage.assertDeleteAccountSectionVisible(true);
personalInfoPage.clickOpenDeleteExapandable();
personalInfoPage.clickDeleteAccountButton();
loginPage.form().login(testUser);
Assert.assertTrue(deleteAccountActionConfirmPage.isCurrent());
removeDeleteAccountRoleFromUserClientRoles();
deleteAccountActionConfirmPage.clickConfirmAction();
Assert.assertTrue(deleteAccountActionConfirmPage.isErrorMessageDisplayed());
Assert.assertEquals(deleteAccountActionConfirmPage.getErrorMessageText(), "You do not have enough permissions to delete your own account, contact admin.");
}
@Test
public void deleteOwnAccountSucceeds() {
personalInfoPage.navigateTo();
personalInfoPage.assertDeleteAccountSectionVisible(true);
personalInfoPage.clickOpenDeleteExapandable();
personalInfoPage.clickDeleteAccountButton();
loginPage.form().login(testUser);
deleteAccountActionConfirmPage.isCurrent();
deleteAccountActionConfirmPage.clickConfirmAction();
events.expectAccount(EventType.DELETE_ACCOUNT);
Assert.assertTrue(testRealmResource().users().search(testUser.getUsername()).isEmpty());
}
private void addDeleteAccountRoleToUserClientRoles() {
ApiUtil.assignClientRoles(testRealmResource(), testUser.getId(), "account",AccountRoles.DELETE_ACCOUNT);
}
private void disableDeleteAccountRequiredAction() {
RequiredActionProviderRepresentation deleteAccount = testRealmResource().flows().getRequiredAction("delete_account");
deleteAccount.setEnabled(false);
testRealmResource().flows().updateRequiredAction("delete_account", deleteAccount);
}
private void enableDeleteAccountRequiredAction() {
RequiredActionProviderRepresentation deleteAccount = testRealmResource().flows().getRequiredAction("delete_account");
deleteAccount.setEnabled(true);
testRealmResource().flows().updateRequiredAction("delete_account", deleteAccount);
}
private void removeDeleteAccountRoleFromUserClientRoles() {
ClientRepresentation clientRepresentation = testRealmResource().clients().findByClientId("account").get(0);
String deleteRoleId = testUserResource().roles().clientLevel(clientRepresentation.getId()).listAll().stream().filter(role -> Objects.equals(role.getName(), "delete-account")).findFirst().get().getId();
RoleRepresentation deleteRole = new RoleRepresentation();
deleteRole.setName("delete-account");
deleteRole.setId(deleteRoleId);
testUserResource().roles().clientLevel(clientRepresentation.getId()).remove(Arrays.asList(deleteRole));
}
}

View file

@ -18,13 +18,18 @@
package org.keycloak.testsuite.ui.account2.page;
import org.keycloak.representations.idm.UserRepresentation;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.ui.Select;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.keycloak.testsuite.util.UIAssert.assertElementDisabled;
import static org.keycloak.testsuite.util.UIAssert.assertInputElementValid;
import static org.keycloak.testsuite.util.UIUtils.clickLink;
import static org.keycloak.testsuite.util.UIUtils.getTextInputValue;
import static org.keycloak.testsuite.util.UIUtils.isElementVisible;
import static org.keycloak.testsuite.util.UIUtils.setTextInputValue;
import static org.junit.Assert.assertEquals;
@ -47,6 +52,8 @@ public class PersonalInfoPage extends AbstractLoggedInPage {
private WebElement saveBtn;
@FindBy(id = "cancel-btn")
private WebElement cancelBtn;
@FindBy(id = "delete-account")
private WebElement deleteAccountSection;
@Override
public String getPageId() {
@ -109,6 +116,14 @@ public class PersonalInfoPage extends AbstractLoggedInPage {
assertElementDisabled(expected, saveBtn);
}
public void assertDeleteAccountSectionVisible(boolean expected) {
if (deleteAccountSection == null) {
assertFalse(expected);
return;
}
assertEquals(expected, isElementVisible(deleteAccountSection));
}
public void clickSave() {
clickSave(true);
}
@ -124,6 +139,14 @@ public class PersonalInfoPage extends AbstractLoggedInPage {
cancelBtn.click();
}
public void clickOpenDeleteExapandable() {
clickLink(driver.findElement(By.cssSelector(".pf-c-expandable__toggle")));
}
public void clickDeleteAccountButton() {
clickLink(driver.findElement(By.id("delete-account-btn")));
}
public void setValues(UserRepresentation user, boolean includeUsername) {
if (includeUsername) {setUsername(user.getUsername());}
setEmail(user.getEmail());

View file

@ -6,6 +6,7 @@ doAdd=Add
doSignOut=Sign Out
doLogIn=Log In
doLink=Link
noAccessMessage=Access not allowed
editAccountHtmlTitle=Edit Account
@ -150,6 +151,12 @@ totpInterval=Interval
totpCounter=Counter
totpDeviceName=Device Name
irreversibleAction=This action is irreversible
deletingImplies=Deleting your account implies:
errasingData=Erasing all your data
loggingOutImmediately=Logging you out immediately
accountUnusable=Any subsequent use of the application will not be possible with this account
missingUsernameMessage=Please specify username.
missingFirstNameMessage=Please specify first name.
invalidEmailMessage=Invalid email address.

View file

@ -0,0 +1,33 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout; section>
<#if section = "header">
${msg("deleteAccountConfirm")}
<#elseif section = "form">
<form action="${url.loginAction}" class="form-vertical" method="post">
<div class="alert alert-warning" style="margin-top:0 !important;margin-bottom:30px !important">
<span class="pficon pficon-warning-triangle-o"></span>
${msg("irreversibleAction")}
</div>
<p>${msg("deletingImplies")}</p>
<ul style="color: #72767b;list-style: disc;list-style-position: inside;">
<li>${msg("loggingOutImmediately")}</li>
<li>${msg("errasingData")}</li>
</ul>
<p class="delete-account-text">${msg("finalDeletionConfirmation")}</p>
<div id="kc-form-buttons">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doConfirmDelete")}" />
<#if triggered_from_aia>
<button class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!}" style="margin-left: calc(100% - 220px)" type="submit" name="cancel-aia" value="true" />${msg("doCancel")}</button>
</#if>
</div>
</form>
</#if>
</@layout.registrationLayout>

View file

@ -14,6 +14,9 @@ doClickHere=Click here
doImpersonate=Impersonate
doTryAgain=Try again
doTryAnotherWay=Try Another Way
doConfirmDelete=Confirm deletion
errorDeletingAccount=Error happened while deleting account
deletingAccountForbidden=You do not have enough permissions to delete your own account, contact admin.
kerberosNotConfigured=Kerberos Not Configured
kerberosNotConfiguredTitle=Kerberos Not Configured
bypassKerberosDetail=Either you are not logged in by Kerberos or your browser is not set up for Kerberos login. Please click continue to login in through other means
@ -379,3 +382,13 @@ webauthn-error-user-not-found=Unknown user authenticated by the Security key.
identity-provider-redirector=Connect with another Identity Provider
identity-provider-login-label=Or sign in with
finalDeletionConfirmation=If you delete your account, it cannot be restored. To keep your account, click Cancel.
irreversibleAction=This action is irreversible
deleteAccountConfirm=Delete account confirmation
deletingImplies=Deleting your account implies:
errasingData=Erasing all your data
loggingOutImmediately=Logging you out immediately
accountUnusable=Any subsequent use of the application will not be possible with this account
userDeletedSuccessfully=User deleted successfully

View file

@ -45,7 +45,8 @@
isLinkedAccountsEnabled : ${realm.identityFederationEnabled?c},
isEventsEnabled : ${isEventsEnabled?c},
isMyResourcesEnabled : ${(realm.userManagedAccessAllowed && isAuthorizationEnabled)?c},
isTotpConfigured : ${isTotpConfigured?c}
isTotpConfigured : ${isTotpConfigured?c},
deleteAccountAllowed : ${deleteAccountAllowed?c}
}
var availableLocales = [];

View file

@ -5,6 +5,7 @@ forbidden=Forbidden
needAccessRights=You do not have access rights to this request. Contact your administrator.
invalidRoute={0} is not a valid route.
actionRequiresIDP=This action requires redirection to your identity provider.
actionNotDefined=No Action defined
continue=Continue
refreshPage=Refresh the page
done=Done
@ -112,3 +113,9 @@ removeModalTitle=Remove Access
removeModalMessage=This will remove the currently granted access permission for {0}. You will need to grant access again if you want to use this app.
confirmButton=Confirm
infoMessage=By clicking 'Remove Access', you will remove granted permissions of this application. This application will no longer use your information.
#Delete Account page
doDelete=Delete
deleteAccountSummary=Deleting your account will erase all your data and log you out immediately.
deleteAccount=Delete Account
deleteAccountWarning=This is irreversible. All your data will be permanently destroyed, and irretrievable.

View file

@ -1,3 +1,16 @@
.brand {
height: 35px;
}
.delete-button {
width: 150px;
height: 50px;
}
@media (max-width: 320px) {
.delete-button {
width: 120px;
height: 50px;
}
}

View file

@ -14,15 +14,18 @@
* limitations under the License.
*/
import * as React from 'react';
import { ActionGroup, Button, Form, FormGroup, TextInput } from '@patternfly/react-core';
import { ActionGroup, Button, Form, FormGroup, TextInput, Grid, GridItem, Expandable} from '@patternfly/react-core';
import { HttpResponse, AccountServiceClient } from '../../account-service/account.service';
import { HttpResponse } from '../../account-service/account.service';
import { AccountServiceContext } from '../../account-service/AccountServiceContext';
import { Features } from '../../widgets/features';
import { Msg } from '../../widgets/Msg';
import { ContentPage } from '../ContentPage';
import { ContentAlert } from '../ContentAlert';
import { LocaleSelector } from '../../widgets/LocaleSelectors';
import { KeycloakContext } from '../../keycloak-service/KeycloakContext';
import { KeycloakService } from '../../keycloak-service/keycloak.service';
import { AIACommand } from '../../util/AIACommand';
declare const features: Features;
declare const locale: string;
@ -51,6 +54,7 @@ export class AccountPage extends React.Component<AccountPageProps, AccountPageSt
context: React.ContextType<typeof AccountServiceContext>;
private isRegistrationEmailAsUsername: boolean = features.isRegistrationEmailAsUsername;
private isEditUserNameAllowed: boolean = features.isEditUserNameAllowed;
private isDeleteAccountAllowed: boolean = features.deleteAccountAllowed;
private readonly DEFAULT_STATE: AccountPageState = {
errors: {
username: '',
@ -130,6 +134,10 @@ export class AccountPage extends React.Component<AccountPageProps, AccountPageSt
}
private handleDelete = (keycloak: KeycloakService): void => {
new AIACommand(keycloak, "delete_account").execute();
}
public render(): React.ReactNode {
const fields: FormFields = this.state.formFields;
return (
@ -236,6 +244,29 @@ export class AccountPage extends React.Component<AccountPageProps, AccountPageSt
</Button>
</ActionGroup>
</Form>
{ this.isDeleteAccountAllowed &&
<div id="delete-account" style={{marginTop:"30px"}}>
<Expandable toggleText="Delete Account">
<Grid gutter={"sm"}>
<GridItem span={6}>
<p>
<Msg msgKey="deleteAccountWarning" />
</p>
</GridItem>
<GridItem span={4}>
<KeycloakContext.Consumer>
{ (keycloak: KeycloakService) => (
<Button id="delete-account-btn" variant="danger" onClick={() => this.handleDelete(keycloak)} className="delete-button"><Msg msgKey="doDelete" /></Button>
)}
</KeycloakContext.Consumer>
</GridItem>
<GridItem span={2}>
</GridItem>
</Grid>
</Expandable>
</div>}
</ContentPage>
);
}

View file

@ -23,6 +23,7 @@
isEventsEnabled: boolean;
isMyResourcesEnabled: boolean;
isTotpConfigured: boolean;
deleteAccountAllowed: boolean;
}