KEYCLOAK-217 Add option to recover username

This commit is contained in:
Stian Thorgersen 2013-12-07 13:18:06 +00:00
parent 0dad786b35
commit cd8c8d52e8
19 changed files with 336 additions and 14 deletions

View file

@ -47,6 +47,10 @@ public class MessageBean {
return summary; return summary;
} }
public String getType() {
return this.type.toString().toLowerCase();
}
public boolean isSuccess(){ public boolean isSuccess(){
return FormFlows.MessageType.SUCCESS.equals(this.type); return FormFlows.MessageType.SUCCESS.equals(this.type);
} }

View file

@ -63,4 +63,8 @@ public class RealmBean {
return realm.isRegistrationAllowed(); return realm.isRegistrationAllowed();
} }
public boolean isResetPasswordAllowed() {
return realm.isResetPasswordAllowed();
}
} }

View file

@ -135,6 +135,10 @@ public class UrlBean {
return Urls.loginPasswordReset(baseURI, realm.getId()).toString(); return Urls.loginPasswordReset(baseURI, realm.getId()).toString();
} }
public String getLoginUsernameReminderUrl() {
return Urls.loginUsernameReminder(baseURI, realm.getId()).toString();
}
public String getLoginEmailVerificationUrl() { public String getLoginEmailVerificationUrl() {
return Urls.loginActionEmailVerification(baseURI, realm.getId()).toString(); return Urls.loginActionEmailVerification(baseURI, realm.getId()).toString();
} }

View file

@ -64,6 +64,7 @@ public class FormServiceImpl implements FormService {
commandMap.put(Pages.LOGIN_UPDATE_PROFILE, new CommandCommon()); commandMap.put(Pages.LOGIN_UPDATE_PROFILE, new CommandCommon());
commandMap.put(Pages.PASSWORD, new CommandCommon()); commandMap.put(Pages.PASSWORD, new CommandCommon());
commandMap.put(Pages.LOGIN_RESET_PASSWORD, new CommandCommon()); commandMap.put(Pages.LOGIN_RESET_PASSWORD, new CommandCommon());
commandMap.put(Pages.LOGIN_USERNAME_REMINDER, new CommandCommon());
commandMap.put(Pages.LOGIN_UPDATE_PASSWORD, new CommandCommon()); commandMap.put(Pages.LOGIN_UPDATE_PASSWORD, new CommandCommon());
commandMap.put(Pages.ACCESS, new CommandCommon()); commandMap.put(Pages.ACCESS, new CommandCommon());
commandMap.put(Pages.SOCIAL, new CommandCommon()); commandMap.put(Pages.SOCIAL, new CommandCommon());

View file

@ -0,0 +1,25 @@
<#import "template-login-action.ftl" as layout>
<@layout.registrationLayout bodyClass="reset" isSeparator=true forceSeparator=true; section>
<#if section = "title">
${rb.getString('emailUsernameForgotHeader')}
<#elseif section = "header">
${rb.getString('emailUsernameForgotHeader')}
<#elseif section = "form">
<div id="form">
<p class="instruction">${rb.getString('emailUsernameInstruction')}</p>
<form action="${url.loginUsernameReminderUrl}" method="post">
<div>
<label for="email">${rb.getString('email')}</label><input type="text" id="email" name="email" />
</div>
<input class="btn-primary" type="submit" value="Submit" />
</form>
</div>
<#elseif section = "info" >
<p><a href="${url.loginUrl}">&laquo; Back to Login</a></p>
</#if>
</@layout.registrationLayout>

View file

@ -24,10 +24,6 @@
<input class="btn-primary" name="login" type="submit" value="Log In"/> <input class="btn-primary" name="login" type="submit" value="Log In"/>
<input class="btn-secondary" name="cancel" type="submit" value="Cancel"/> <input class="btn-secondary" name="cancel" type="submit" value="Cancel"/>
</div> </div>
<div class="aside-btn">
<p>Forgot <a href="${url.loginPasswordResetUrl}">Password</a>?</p>
</div>
</form> </form>
</div> </div>
@ -37,6 +33,9 @@
<#if realm.registrationAllowed> <#if realm.registrationAllowed>
<p>${rb.getString('noAccount')} <a href="${url.registrationUrl}">${rb.getString('register')}</a>.</p> <p>${rb.getString('noAccount')} <a href="${url.registrationUrl}">${rb.getString('register')}</a>.</p>
</#if> </#if>
<#if realm.resetPasswordAllowed>
<p>Forgot <a href="${url.loginUsernameReminderUrl}">Username</a> / <a href="${url.loginPasswordResetUrl}">Password</a>?</p>
</#if>
</div> </div>
</#if> </#if>

View file

@ -44,6 +44,13 @@
</p> </p>
</div> </div>
</#if> </#if>
<#if message?has_content && message.success>
<div class="feedback success bottom-left show">
<p>
<strong>${rb.getString('successHeader')}</strong><br/>${message.summary}
</p>
</div>
</#if>
<#nested "form"> <#nested "form">
</div> </div>

View file

@ -64,5 +64,9 @@ emailError=Invalid username or email.
emailErrorInfo=Please, fill in the fields again. emailErrorInfo=Please, fill in the fields again.
emailInstruction=Enter your username and email address and we will send you instructions on how to create a new password. emailInstruction=Enter your username and email address and we will send you instructions on how to create a new password.
emailUsernameForgotHeader=Forgot Your Username?
emailUsernameInstruction=Enter your email address and we will send you an email with your username.
emailUsernameSent=You should receive an email shortly with your username.
accountUpdated=Your account has been updated accountUpdated=Your account has been updated
accountPasswordUpdated=Your password has been updated accountPasswordUpdated=Your password has been updated

View file

@ -86,6 +86,8 @@ public interface RealmModel extends RoleContainerModel, RoleMapperModel, ScopeMa
UserModel getUser(String name); UserModel getUser(String name);
UserModel getUserByEmail(String email);
UserModel addUser(String username); UserModel addUser(String username);
boolean removeUser(String name); boolean removeUser(String name);

View file

@ -439,6 +439,15 @@ public class RealmAdapter implements RealmModel {
return new UserAdapter(results.get(0)); return new UserAdapter(results.get(0));
} }
@Override
public UserModel getUserByEmail(String email) {
TypedQuery<UserEntity> query = em.createNamedQuery("getRealmUserByEmail", UserEntity.class);
query.setParameter("email", email);
query.setParameter("realm", realm);
List<UserEntity> results = query.getResultList();
return results.isEmpty()? null : new UserAdapter(results.get(0));
}
@Override @Override
public UserModel addUser(String username) { public UserModel addUser(String username) {
UserEntity entity = new UserEntity(); UserEntity entity = new UserEntity();

View file

@ -509,6 +509,14 @@ public class RealmAdapter implements RealmModel {
return new UserAdapter(user, getIdm()); return new UserAdapter(user, getIdm());
} }
@Override
public UserModel getUserByEmail(String email) {
IdentityQuery<User> query = getIdm().createIdentityQuery(User.class);
query.setParameter(User.EMAIL, email);
List<User> users = query.getResultList();
return users.isEmpty() ? null : new UserAdapter(users.get(0), getIdm());
}
protected User findPicketlinkUser(String name) { protected User findPicketlinkUser(String name) {
return SampleModel.getUser(getIdm(), name); return SampleModel.getUser(getIdm(), name);
} }

View file

@ -108,16 +108,17 @@ public class EmailSender {
URI uri = builder.build(realm.getId()); URI uri = builder.build(realm.getId());
StringBuilder sb = new StringBuilder();
sb.append("Hi ").append(user.getFirstName()).append(",\n\n"); StringBuilder sb = getHeader(user);
sb.append("Someone has created a Keycloak account with this email address. "); sb.append("Someone has created a Keycloak account with this email address. ");
sb.append("If this was you, click the link below to verify your email address:\n"); sb.append("If this was you, click the link below to verify your email address:\n");
sb.append(uri.toString()); sb.append(uri.toString());
sb.append("\n\nThis link will expire within ").append(TimeUnit.SECONDS.toMinutes(realm.getAccessCodeLifespanUserAction())); sb.append("\n\nThis link will expire within ").append(TimeUnit.SECONDS.toMinutes(realm.getAccessCodeLifespanUserAction()));
sb.append(" minutes.\n\n"); sb.append(" minutes.\n\n");
sb.append("If you didn't create this account, just ignore this message.\n\n"); sb.append("If you didn't create this account, just ignore this message.\n");
sb.append("Thanks,\n");
sb.append("The Keycloak Team"); addFooter(sb);
send(user.getEmail(), "Verify email", sb.toString()); send(user.getEmail(), "Verify email", sb.toString());
} }
@ -128,19 +129,44 @@ public class EmailSender {
URI uri = builder.build(realm.getId()); URI uri = builder.build(realm.getId());
StringBuilder sb = new StringBuilder(); StringBuilder sb = getHeader(user);
sb.append("Hi ").append(user.getFirstName()).append(",\n\n");
sb.append("Someone just requested to change your Keycloak account's password. "); sb.append("Someone just requested to change your Keycloak account's password. ");
sb.append("If this was you, click on the link below to set a new password:\n"); sb.append("If this was you, click on the link below to set a new password:\n");
sb.append(uri.toString()); sb.append(uri.toString());
sb.append("\n\nThis link will expire within ").append(TimeUnit.SECONDS.toMinutes(realm.getAccessCodeLifespanUserAction())); sb.append("\n\nThis link will expire within ").append(TimeUnit.SECONDS.toMinutes(realm.getAccessCodeLifespanUserAction()));
sb.append(" minutes.\n\n"); sb.append(" minutes.\n\n");
sb.append("If you don't want to reset your password, just ignore this message and nothing will be changed.\n\n"); sb.append("If you don't want to reset your password, just ignore this message and nothing will be changed.\n");
sb.append("Thanks,\n");
sb.append("The Keycloak Team"); addFooter(sb);
send(user.getEmail(), "Reset password link", sb.toString()); send(user.getEmail(), "Reset password link", sb.toString());
} }
public void sendUsernameReminder(UserModel user) throws EmailException {
StringBuilder sb = getHeader(user);
sb.append("The username for your Keycloak account is ").append(user.getLoginName()).append(".\n");
addFooter(sb);
send(user.getEmail(), "Username reminder", sb.toString());
}
private StringBuilder getHeader(UserModel user) {
StringBuilder sb = new StringBuilder();
sb.append("Hi");
if (user.getFirstName() != null) {
sb.append(" ").append(user.getFirstName());
}
sb.append(",\n\n");
return sb;
}
private void addFooter(StringBuilder sb) {
sb.append("\nThanks,\nThe Keycloak Team");
}
} }

View file

@ -276,6 +276,45 @@ public class RequiredActionsService {
return Flows.forms(realm, request, uriInfo).setSuccess("emailSent").forwardToPasswordReset(); return Flows.forms(realm, request, uriInfo).setSuccess("emailSent").forwardToPasswordReset();
} }
@Path("username-reminder")
@GET
public Response usernameReminder() {
return Flows.forms(realm, request, uriInfo).forwardToUsernameReminder();
}
@Path("username-reminder")
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response sendUsernameReminder(final MultivaluedMap<String, String> formData) {
String email = formData.getFirst("email");
String clientId = uriInfo.getQueryParameters().getFirst("client_id");
UserModel client = realm.getUser(clientId);
if (client == null) {
return Flows.oauth(realm, request, uriInfo, authManager, tokenManager).forwardToSecurityFailure(
"Unknown login requester.");
}
if (!client.isEnabled()) {
return Flows.oauth(realm, request, uriInfo, authManager, tokenManager).forwardToSecurityFailure(
"Login requester not enabled.");
}
UserModel user = realm.getUserByEmail(email);
if (user == null) {
return Flows.forms(realm, request, uriInfo).setError("emailError").forwardToUsernameReminder();
}
try {
new EmailSender(realm.getSmtpConfig()).sendUsernameReminder(user);
} catch (EmailException e) {
logger.error("Failed to send username reminder email", e);
return Flows.forms(realm, request, uriInfo).setError("emailSendError").forwardToErrorPage();
}
return Flows.forms(realm, request, uriInfo).setSuccess("emailUsernameSent").forwardToLogin();
}
private AccessCodeEntry getAccessCodeEntry(RequiredAction requiredAction) { private AccessCodeEntry getAccessCodeEntry(RequiredAction requiredAction) {
String code = uriInfo.getQueryParameters().getFirst(FormFlows.CODE); String code = uriInfo.getQueryParameters().getFirst(FormFlows.CODE);
if (code == null) { if (code == null) {

View file

@ -176,6 +176,10 @@ public class FormFlows {
return forwardToForm(Pages.LOGIN_RESET_PASSWORD); return forwardToForm(Pages.LOGIN_RESET_PASSWORD);
} }
public Response forwardToUsernameReminder() {
return forwardToForm(Pages.LOGIN_USERNAME_REMINDER);
}
public Response forwardToLoginTotp() { public Response forwardToLoginTotp() {
return forwardToForm(Pages.LOGIN_TOTP); return forwardToForm(Pages.LOGIN_TOTP);
} }

View file

@ -46,6 +46,8 @@ public class Pages {
public final static String LOGIN_UPDATE_PASSWORD = "login-update-password.ftl"; public final static String LOGIN_UPDATE_PASSWORD = "login-update-password.ftl";
public final static String LOGIN_USERNAME_REMINDER = "login-username-reminder.ftl";
public final static String REGISTER = "register.ftl"; public final static String REGISTER = "register.ftl";
public final static String ERROR = "error.ftl"; public final static String ERROR = "error.ftl";

View file

@ -100,6 +100,14 @@ public class Urls {
return requiredActionsBase(baseUri).path(RequiredActionsService.class, "passwordReset"); return requiredActionsBase(baseUri).path(RequiredActionsService.class, "passwordReset");
} }
public static URI loginUsernameReminder(URI baseUri, String realmId) {
return loginUsernameReminderBuilder(baseUri).build(realmId);
}
public static UriBuilder loginUsernameReminderBuilder(URI baseUri) {
return requiredActionsBase(baseUri).path(RequiredActionsService.class, "usernameReminder");
}
private static UriBuilder realmBase(URI baseUri) { private static UriBuilder realmBase(URI baseUri) {
return UriBuilder.fromUri(baseUri).path(RealmsResource.class); return UriBuilder.fromUri(baseUri).path(RealmsResource.class);
} }

View file

@ -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.forms;
import org.junit.Assert;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.testsuite.OAuthClient;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.AppPage.RequestType;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginPasswordResetPage;
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
import org.keycloak.testsuite.pages.LoginRecoverUsernamePage;
import org.keycloak.testsuite.rule.GreenMailRule;
import org.keycloak.testsuite.rule.KeycloakRule;
import org.keycloak.testsuite.rule.WebResource;
import org.keycloak.testsuite.rule.WebRule;
import org.openqa.selenium.WebDriver;
import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;
import java.io.IOException;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public class LoginRecoverUsernameTest {
@ClassRule
public static KeycloakRule keycloakRule = new KeycloakRule();
@Rule
public WebRule webRule = new WebRule(this);
@Rule
public GreenMailRule greenMail = new GreenMailRule();
@WebResource
protected WebDriver driver;
@WebResource
protected OAuthClient oauth;
@WebResource
protected AppPage appPage;
@WebResource
protected LoginPage loginPage;
@WebResource
protected LoginRecoverUsernamePage recoverUsernamePage;
@Test
public void resetPassword() throws IOException, MessagingException {
loginPage.open();
loginPage.recoverUsername();
recoverUsernamePage.assertCurrent();
recoverUsernamePage.recoverUsername("test-user@localhost");
loginPage.assertCurrent();
Assert.assertTrue(driver.getPageSource().contains("You should receive an email shortly with your username"));
Assert.assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getReceivedMessages()[0];
String body = (String) message.getContent();
Assert.assertTrue(body.contains("The username for your Keycloak account is test-user@localhost"));
}
@Test
public void resetPasswordWrongEmail() throws IOException, MessagingException {
loginPage.open();
loginPage.recoverUsername();
recoverUsernamePage.assertCurrent();
recoverUsernamePage.recoverUsername("invalid");
recoverUsernamePage.assertCurrent();
Assert.assertEquals("Invalid username or email.", recoverUsernamePage.getMessage());
}
}

View file

@ -56,6 +56,9 @@ public class LoginPage extends AbstractPage {
@FindBy(linkText = "Password") @FindBy(linkText = "Password")
private WebElement resetPasswordLink; private WebElement resetPasswordLink;
@FindBy(linkText = "Username")
private WebElement recoverUsernameLink;
@FindBy(id = "loginError") @FindBy(id = "loginError")
private WebElement loginErrorMessage; private WebElement loginErrorMessage;
@ -93,6 +96,11 @@ public class LoginPage extends AbstractPage {
resetPasswordLink.click(); resetPasswordLink.click();
} }
public void recoverUsername() {
recoverUsernameLink.click();
}
@Override @Override
public void open() { public void open() {
oauth.openLoginForm(); oauth.openLoginForm();

View file

@ -0,0 +1,59 @@
/*
* 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.pages;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public class LoginRecoverUsernamePage extends AbstractPage {
@FindBy(id = "email")
private WebElement emailInput;
@FindBy(css = "input[type=\"submit\"]")
private WebElement submitButton;
@FindBy(css = ".feedback > p > strong")
private WebElement emailErrorMessage;
public void recoverUsername(String email) {
emailInput.sendKeys(email);
submitButton.click();
}
public boolean isCurrent() {
return driver.getTitle().equals("Forgot Your Username?");
}
public void open() {
throw new UnsupportedOperationException();
}
public String getMessage() {
return emailErrorMessage != null ? emailErrorMessage.getText() : null;
}
}