diff --git a/services/src/main/java/org/keycloak/services/email/EmailSender.java b/services/src/main/java/org/keycloak/services/email/EmailSender.java index 8b86fcf665..8da383a699 100644 --- a/services/src/main/java/org/keycloak/services/email/EmailSender.java +++ b/services/src/main/java/org/keycloak/services/email/EmailSender.java @@ -24,7 +24,6 @@ package org.keycloak.services.email; import java.net.URI; import java.util.Map.Entry; import java.util.concurrent.TimeUnit; -import java.util.Date; import java.util.List; import java.util.Properties; @@ -39,7 +38,6 @@ import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriInfo; import org.jboss.resteasy.logging.Logger; -import org.keycloak.services.managers.AccessCodeEntry; import org.keycloak.services.models.RealmModel; import org.keycloak.services.models.UserModel; import org.keycloak.services.resources.AccountService; @@ -101,4 +99,25 @@ public class EmailSender { } } + public void sendPasswordReset(UserModel user, RealmModel realm, String code, UriInfo uriInfo) { + UriBuilder builder = Urls.accountBase(uriInfo.getBaseUri()).path(AccountService.class, "passwordPage"); + for (Entry> e : uriInfo.getQueryParameters().entrySet()) { + builder.queryParam(e.getKey(), e.getValue().toArray()); + } + builder.queryParam("code", code); + + URI uri = builder.build(realm.getId()); + + StringBuilder sb = new StringBuilder(); + sb.append(uri.toString()); + sb.append("\n"); + sb.append("Expires in " + TimeUnit.SECONDS.toMinutes(realm.getAccessCodeLifespanUserAction())); + + try { + send(user.getEmail(), "Reset password link", sb.toString()); + } catch (Exception e) { + log.warn("Failed to send reset password link", e); + } + } + } diff --git a/services/src/main/java/org/keycloak/services/managers/RealmManager.java b/services/src/main/java/org/keycloak/services/managers/RealmManager.java index f6f836f5d7..6fd1ee54d0 100755 --- a/services/src/main/java/org/keycloak/services/managers/RealmManager.java +++ b/services/src/main/java/org/keycloak/services/managers/RealmManager.java @@ -212,6 +212,7 @@ public class RealmManager { public UserModel createUser(RealmModel newRealm, UserRepresentation userRep) { UserModel user = newRealm.addUser(userRep.getUsername()); user.setStatus(UserModel.Status.valueOf(userRep.getStatus())); + user.setEmail(userRep.getEmail()); if (userRep.getAttributes() != null) { for (Map.Entry entry : userRep.getAttributes().entrySet()) { user.setAttribute(entry.getKey(), entry.getValue()); diff --git a/services/src/main/java/org/keycloak/services/resources/AccountService.java b/services/src/main/java/org/keycloak/services/resources/AccountService.java index c69c39560f..e8160a27d7 100755 --- a/services/src/main/java/org/keycloak/services/resources/AccountService.java +++ b/services/src/main/java/org/keycloak/services/resources/AccountService.java @@ -39,8 +39,10 @@ import org.jboss.resteasy.jose.jws.JWSInput; import org.jboss.resteasy.jose.jws.crypto.RSAProvider; import org.jboss.resteasy.spi.HttpRequest; import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.services.email.EmailSender; import org.keycloak.services.managers.AccessCodeEntry; import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.managers.ResourceAdminManager; import org.keycloak.services.managers.TokenManager; import org.keycloak.services.messages.Messages; import org.keycloak.services.models.RealmModel; @@ -49,6 +51,7 @@ import org.keycloak.services.models.UserModel; import org.keycloak.services.models.UserModel.RequiredAction; import org.keycloak.services.resources.flows.Flows; import org.keycloak.services.resources.flows.FormFlows; +import org.keycloak.services.resources.flows.OAuthFlows; import org.keycloak.services.validation.Validation; import org.picketlink.idm.credential.util.TimeBasedOTP; @@ -253,14 +256,18 @@ public class AccountService { String error = null; - if (Validation.isEmpty(password)) { - error = Messages.MISSING_PASSWORD; - } else if (Validation.isEmpty(passwordNew)) { + if (Validation.isEmpty(passwordNew)) { error = Messages.MISSING_PASSWORD; } else if (!passwordNew.equals(passwordConfirm)) { error = Messages.INVALID_PASSWORD_CONFIRM; - } else if (!realm.validatePassword(user, password)) { - error = Messages.INVALID_PASSWORD_EXISTING; + } + + if (user.getRequiredActions() == null || !user.getRequiredActions().contains(RequiredAction.RESET_PASSWORD)) { + if (Validation.isEmpty(password)) { + error = Messages.MISSING_PASSWORD; + } else if (!realm.validatePassword(user, password)) { + error = Messages.INVALID_PASSWORD_EXISTING; + } } if (error != null) { @@ -273,12 +280,13 @@ public class AccountService { realm.updateCredential(user, credentials); - Response response = redirectOauth(); - if (response != null) { - return response; - } else { - return Flows.forms(realm, request, uriInfo).setUser(user).forwardToPassword(); - } + user.removeRequiredAction(RequiredAction.RESET_PASSWORD); + user.setStatus(UserModel.Status.ENABLED); + + authManager.expireIdentityCookie(realm, uriInfo); + new ResourceAdminManager().singleLogOut(realm, user.getLoginName()); + + return Flows.forms(realm, request, uriInfo).forwardToLogin(); } else { return Response.status(Status.FORBIDDEN).build(); } @@ -320,7 +328,7 @@ public class AccountService { @Path("password") @GET public Response passwordPage() { - UserModel user = getUserFromAuthManager(); + UserModel user = getUser(RequiredAction.RESET_PASSWORD); if (user != null) { return Flows.forms(realm, request, uriInfo).setUser(user).forwardToPassword(); } else { @@ -328,4 +336,40 @@ public class AccountService { } } + @Path("password-reset") + @GET + public Response resetPassword(@QueryParam("username") final String username, + @QueryParam("client_id") final String clientId, @QueryParam("scope") final String scopeParam, + @QueryParam("state") final String state, @QueryParam("redirect_uri") final String redirect) { + + OAuthFlows oauth = Flows.oauth(realm, request, uriInfo, authManager, tokenManager); + + if (!realm.isEnabled()) { + return oauth.forwardToSecurityFailure("Realm not enabled."); + } + UserModel client = realm.getUser(clientId); + if (client == null) { + return oauth.forwardToSecurityFailure("Unknown login requester."); + } + if (!client.isEnabled()) { + return oauth.forwardToSecurityFailure("Login requester not enabled."); + } + + // String username = formData.getFirst("username"); + UserModel user = realm.getUser(username); + user.addRequiredAction(RequiredAction.RESET_PASSWORD); + user.setStatus(UserModel.Status.ACTIONS_REQUIRED); + + AccessCodeEntry accessCode = tokenManager.createAccessCode(scopeParam, state, redirect, realm, client, user); + accessCode.setExpiration(System.currentTimeMillis() / 1000 + realm.getAccessCodeLifespanUserAction()); + + if (user.getEmail() == null) { + return oauth.forwardToSecurityFailure("Email address not set, contact admin"); + } + + new EmailSender().sendPasswordReset(user, realm, accessCode.getCode(), uriInfo); + // TODO Add info message + return Flows.forms(realm, request, uriInfo).forwardToLogin(); + } + } diff --git a/testsuite/src/test/java/org/keycloak/testsuite/AccountTest.java b/testsuite/src/test/java/org/keycloak/testsuite/AccountTest.java index d9418e1c33..9fef302766 100644 --- a/testsuite/src/test/java/org/keycloak/testsuite/AccountTest.java +++ b/testsuite/src/test/java/org/keycloak/testsuite/AccountTest.java @@ -21,10 +21,13 @@ */ package org.keycloak.testsuite; +import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.junit.Arquillian; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; +import org.keycloak.testsuite.pages.ChangePasswordPage; +import org.keycloak.testsuite.pages.UpdateProfilePage; /** * @author Stian Thorgersen @@ -32,44 +35,52 @@ import org.junit.runner.RunWith; @RunWith(Arquillian.class) public class AccountTest extends AbstractDroneTest { + @Page + protected ChangePasswordPage changePasswordPage; + + @Page + protected UpdateProfilePage profilePage; + @Test public void changePassword() { - // registerUser("changePassword", "password"); - // - // browser.open(authServerUrl + "/rest/realms/demo/account/password"); - // browser.waitForPageToLoad(DEFAULT_WAIT); - // - // Assert.assertTrue(browser.isTextPresent("Change Password")); - // - // browser.type("id=password", "password"); - // browser.type("id=password-new", "newpassword"); - // browser.type("id=password-confirm", "newpassword"); - // browser.click("css=input[type=\"submit\"]"); - // browser.waitForPageToLoad(DEFAULT_WAIT); - // - // logout(); - // - // login("changePassword", "password", "Invalid username or password"); - // login("changePassword", "newpassword"); + appPage.open(); + loginPage.register(); + registerPage.register("name", "email", "changePassword", "password", "password"); + + changePasswordPage.open(); + changePasswordPage.changePassword("password", "new-password", "new-password"); + + appPage.open(); + + Assert.assertTrue(loginPage.isCurrent()); + + loginPage.login("changePassword", "password"); + + Assert.assertEquals("Invalid username or password", loginPage.getError()); + + loginPage.login("changePassword", "new-password"); + + Assert.assertTrue(appPage.isCurrent()); + Assert.assertEquals("changePassword", appPage.getUser()); } @Test public void changeProfile() { - // registerUser("changeProfile", "password"); - // - // browser.open(authServerUrl + "/rest/realms/demo/account"); - // browser.waitForPageToLoad(DEFAULT_WAIT); - // - // browser.type("id=firstName", "Newfirst"); - // browser.type("id=lastName", "Newlast"); - // browser.type("id=email", "new@email.com"); - // - // browser.click("css=input[type=\"submit\"]"); - // browser.waitForPageToLoad(DEFAULT_WAIT); - // - // Assert.assertEquals("Newfirst", browser.getValue("id=firstName")); - // Assert.assertEquals("Newlast", browser.getValue("id=lastName")); - // Assert.assertEquals("new@email.com", browser.getValue("id=email")); + appPage.open(); + loginPage.register(); + registerPage.register("first last", "old@email.com", "changeProfile", "password", "password"); + + profilePage.open(); + + Assert.assertEquals("first", profilePage.getFirstName()); + Assert.assertEquals("last", profilePage.getLastName()); + Assert.assertEquals("old@email.com", profilePage.getEmail()); + + profilePage.updateProfile("New first", "New last", "new@email.com"); + + Assert.assertEquals("New first", profilePage.getFirstName()); + Assert.assertEquals("New last", profilePage.getLastName()); + Assert.assertEquals("new@email.com", profilePage.getEmail()); } } diff --git a/testsuite/src/test/java/org/keycloak/testsuite/ResetPasswordTest.java b/testsuite/src/test/java/org/keycloak/testsuite/ResetPasswordTest.java new file mode 100644 index 0000000000..c5dc1e3c4c --- /dev/null +++ b/testsuite/src/test/java/org/keycloak/testsuite/ResetPasswordTest.java @@ -0,0 +1,109 @@ +/* + * JBoss, Home of Professional Open Source. + * Copyright 2012, Red Hat, Inc., and individual contributors + * as indicated by the @author tags. See the copyright.txt file in the + * distribution for a full listing of individual contributors. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.keycloak.testsuite; + +import java.io.IOException; + +import javax.mail.MessagingException; +import javax.mail.internet.MimeMessage; + +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.arquillian.graphene.page.Page; +import org.jboss.arquillian.junit.Arquillian; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.keycloak.testsuite.pages.ChangePasswordPage; + +import com.icegreen.greenmail.util.GreenMail; +import com.icegreen.greenmail.util.ServerSetup; + +/** + * @author Stian Thorgersen + */ +@RunWith(Arquillian.class) +public class ResetPasswordTest extends AbstractDroneTest { + + @Deployment(name = "properties", testable = false, order = 1) + public static WebArchive propertiesDeployment() { + return ShrinkWrap.create(WebArchive.class, "properties.war").addClass(SystemPropertiesSetter.class) + .addAsWebInfResource("web-properties-email-verfication.xml", "web.xml"); + } + + private GreenMail greenMail; + + @Page + protected ChangePasswordPage changePasswordPage; + + @Before + public void before() { + ServerSetup setup = new ServerSetup(3025, "localhost", "smtp"); + + greenMail = new GreenMail(setup); + greenMail.start(); + } + + @After + public void after() { + if (greenMail != null) { + greenMail.stop(); + } + } + + @Test + public void resetPassword() throws IOException, MessagingException { + appPage.open(); + + Assert.assertTrue(loginPage.isCurrent()); + + String url = browser.getCurrentUrl(); + url = url.replace("tokens/login", "account/password-reset"); + url = url + "&username=bburke@redhat.com"; + + browser.navigate().to(url); + + MimeMessage message = greenMail.getReceivedMessages()[0]; + + String body = (String) message.getContent(); + String changePasswordUrl = body.split("\n")[0]; + + browser.navigate().to(changePasswordUrl.trim()); + + changePasswordPage.changePassword("new-password", "new-password"); + + Assert.assertTrue(loginPage.isCurrent()); + + loginPage.login("bburke@redhat.com", "password"); + Assert.assertTrue(loginPage.isCurrent()); + Assert.assertEquals("Invalid username or password", loginPage.getError()); + + loginPage.login("bburke@redhat.com", "new-password"); + + Assert.assertTrue(appPage.isCurrent()); + Assert.assertEquals("bburke@redhat.com", appPage.getUser()); + } + +} diff --git a/testsuite/src/test/java/org/keycloak/testsuite/pages/ChangePasswordPage.java b/testsuite/src/test/java/org/keycloak/testsuite/pages/ChangePasswordPage.java new file mode 100644 index 0000000000..1e0185fc19 --- /dev/null +++ b/testsuite/src/test/java/org/keycloak/testsuite/pages/ChangePasswordPage.java @@ -0,0 +1,51 @@ +package org.keycloak.testsuite.pages; + +import org.jboss.arquillian.drone.api.annotation.Drone; +import org.keycloak.testsuite.Constants; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +public class ChangePasswordPage { + + private static String PATH = Constants.AUTH_SERVER_ROOT + "/rest/realms/demo/account/password"; + + @Drone + private WebDriver browser; + + @FindBy(id = "password") + private WebElement passwordInput; + + @FindBy(id = "password-new") + private WebElement newPasswordInput; + + @FindBy(id = "password-confirm") + private WebElement passwordConfirmInput; + + @FindBy(css = "input[type=\"submit\"]") + private WebElement submitButton; + + public void changePassword(String newPassword, String passwordConfirm) { + newPasswordInput.sendKeys(newPassword); + passwordConfirmInput.sendKeys(passwordConfirm); + + submitButton.click(); + } + + public void changePassword(String password, String newPassword, String passwordConfirm) { + passwordInput.sendKeys(password); + newPasswordInput.sendKeys(newPassword); + passwordConfirmInput.sendKeys(passwordConfirm); + + submitButton.click(); + } + + public boolean isCurrent() { + return browser.getPageSource().contains("Change Password"); + } + + public void open() { + browser.navigate().to(PATH); + } + +} diff --git a/testsuite/src/test/java/org/keycloak/testsuite/pages/LoginPage.java b/testsuite/src/test/java/org/keycloak/testsuite/pages/LoginPage.java index 9627c15c6c..0e4a5496c6 100644 --- a/testsuite/src/test/java/org/keycloak/testsuite/pages/LoginPage.java +++ b/testsuite/src/test/java/org/keycloak/testsuite/pages/LoginPage.java @@ -26,7 +26,10 @@ public class LoginPage { private WebElement loginErrorMessage; public void login(String username, String password) { + usernameInput.clear(); usernameInput.sendKeys(username); + + passwordInput.clear(); passwordInput.sendKeys(password); submitButton.click(); diff --git a/testsuite/src/test/java/org/keycloak/testsuite/pages/UpdateProfilePage.java b/testsuite/src/test/java/org/keycloak/testsuite/pages/UpdateProfilePage.java new file mode 100644 index 0000000000..4233041e4e --- /dev/null +++ b/testsuite/src/test/java/org/keycloak/testsuite/pages/UpdateProfilePage.java @@ -0,0 +1,59 @@ +package org.keycloak.testsuite.pages; + +import org.jboss.arquillian.drone.api.annotation.Drone; +import org.keycloak.testsuite.Constants; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +public class UpdateProfilePage { + + private static String PATH = Constants.AUTH_SERVER_ROOT + "/rest/realms/demo/account"; + + @Drone + private WebDriver browser; + + @FindBy(id = "firstName") + private WebElement firstNameInput; + + @FindBy(id = "lastName") + private WebElement lastNameInput; + + @FindBy(id = "email") + private WebElement emailInput; + + @FindBy(css = "input[type=\"submit\"]") + private WebElement submitButton; + + public void updateProfile(String firstName, String lastName, String email) { + firstNameInput.clear(); + firstNameInput.sendKeys(firstName); + lastNameInput.clear(); + lastNameInput.sendKeys(lastName); + emailInput.clear(); + emailInput.sendKeys(email); + + submitButton.click(); + } + + public String getFirstName() { + return firstNameInput.getAttribute("value"); + } + + public String getLastName() { + return lastNameInput.getAttribute("value"); + } + + public String getEmail() { + return emailInput.getAttribute("value"); + } + + public boolean isCurrent() { + return browser.getPageSource().contains("Edit Account"); + } + + public void open() { + browser.navigate().to(PATH); + } + +} diff --git a/testsuite/src/test/resources/testrealm-email.json b/testsuite/src/test/resources/testrealm-email.json index afc48e0cbb..49340fdcca 100755 --- a/testsuite/src/test/resources/testrealm-email.json +++ b/testsuite/src/test/resources/testrealm-email.json @@ -18,9 +18,7 @@ { "username" : "bburke@redhat.com", "status": "ENABLED", - "attributes" : { - "email" : "bburke@redhat.com" - }, + "email" : "bburke@redhat.com", "credentials" : [ { "type" : "password", "value" : "password" } diff --git a/testsuite/src/test/resources/testrealm-totp.json b/testsuite/src/test/resources/testrealm-totp.json index f623e03a12..014339fcdd 100755 --- a/testsuite/src/test/resources/testrealm-totp.json +++ b/testsuite/src/test/resources/testrealm-totp.json @@ -17,9 +17,7 @@ { "username" : "bburke@redhat.com", "status": "ENABLED", - "attributes" : { - "email" : "bburke@redhat.com" - }, + "email" : "bburke@redhat.com", "credentials" : [ { "type" : "password", "value" : "password" } diff --git a/testsuite/src/test/resources/testrealm.json b/testsuite/src/test/resources/testrealm.json index e20d0a374d..2ef8cebb55 100755 --- a/testsuite/src/test/resources/testrealm.json +++ b/testsuite/src/test/resources/testrealm.json @@ -17,9 +17,7 @@ { "username" : "bburke@redhat.com", "status": "ENABLED", - "attributes" : { - "email" : "bburke@redhat.com" - }, + "email" : "bburke@redhat.com", "credentials" : [ { "type" : "password", "value" : "password" }