KEYCLOAK-217 Add option to recover username
This commit is contained in:
parent
0dad786b35
commit
cd8c8d52e8
19 changed files with 336 additions and 14 deletions
|
@ -47,6 +47,10 @@ public class MessageBean {
|
|||
return summary;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return this.type.toString().toLowerCase();
|
||||
}
|
||||
|
||||
public boolean isSuccess(){
|
||||
return FormFlows.MessageType.SUCCESS.equals(this.type);
|
||||
}
|
||||
|
|
|
@ -62,5 +62,9 @@ public class RealmBean {
|
|||
public boolean isRegistrationAllowed() {
|
||||
return realm.isRegistrationAllowed();
|
||||
}
|
||||
|
||||
public boolean isResetPasswordAllowed() {
|
||||
return realm.isResetPasswordAllowed();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -135,6 +135,10 @@ public class UrlBean {
|
|||
return Urls.loginPasswordReset(baseURI, realm.getId()).toString();
|
||||
}
|
||||
|
||||
public String getLoginUsernameReminderUrl() {
|
||||
return Urls.loginUsernameReminder(baseURI, realm.getId()).toString();
|
||||
}
|
||||
|
||||
public String getLoginEmailVerificationUrl() {
|
||||
return Urls.loginActionEmailVerification(baseURI, realm.getId()).toString();
|
||||
}
|
||||
|
|
|
@ -64,6 +64,7 @@ public class FormServiceImpl implements FormService {
|
|||
commandMap.put(Pages.LOGIN_UPDATE_PROFILE, new CommandCommon());
|
||||
commandMap.put(Pages.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.ACCESS, new CommandCommon());
|
||||
commandMap.put(Pages.SOCIAL, new CommandCommon());
|
||||
|
|
|
@ -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}">« Back to Login</a></p>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
|
@ -24,10 +24,6 @@
|
|||
<input class="btn-primary" name="login" type="submit" value="Log In"/>
|
||||
<input class="btn-secondary" name="cancel" type="submit" value="Cancel"/>
|
||||
</div>
|
||||
|
||||
<div class="aside-btn">
|
||||
<p>Forgot <a href="${url.loginPasswordResetUrl}">Password</a>?</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
@ -37,6 +33,9 @@
|
|||
<#if realm.registrationAllowed>
|
||||
<p>${rb.getString('noAccount')} <a href="${url.registrationUrl}">${rb.getString('register')}</a>.</p>
|
||||
</#if>
|
||||
<#if realm.resetPasswordAllowed>
|
||||
<p>Forgot <a href="${url.loginUsernameReminderUrl}">Username</a> / <a href="${url.loginPasswordResetUrl}">Password</a>?</p>
|
||||
</#if>
|
||||
</div>
|
||||
|
||||
</#if>
|
||||
|
|
|
@ -44,6 +44,13 @@
|
|||
</p>
|
||||
</div>
|
||||
</#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">
|
||||
</div>
|
||||
|
||||
|
|
|
@ -64,5 +64,9 @@ emailError=Invalid username or email.
|
|||
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.
|
||||
|
||||
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
|
||||
accountPasswordUpdated=Your password has been updated
|
|
@ -86,6 +86,8 @@ public interface RealmModel extends RoleContainerModel, RoleMapperModel, ScopeMa
|
|||
|
||||
UserModel getUser(String name);
|
||||
|
||||
UserModel getUserByEmail(String email);
|
||||
|
||||
UserModel addUser(String username);
|
||||
|
||||
boolean removeUser(String name);
|
||||
|
|
|
@ -439,6 +439,15 @@ public class RealmAdapter implements RealmModel {
|
|||
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
|
||||
public UserModel addUser(String username) {
|
||||
UserEntity entity = new UserEntity();
|
||||
|
|
|
@ -509,6 +509,14 @@ public class RealmAdapter implements RealmModel {
|
|||
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) {
|
||||
return SampleModel.getUser(getIdm(), name);
|
||||
}
|
||||
|
|
|
@ -108,16 +108,17 @@ public class EmailSender {
|
|||
|
||||
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("If this was you, click the link below to verify your email address:\n");
|
||||
sb.append(uri.toString());
|
||||
sb.append("\n\nThis link will expire within ").append(TimeUnit.SECONDS.toMinutes(realm.getAccessCodeLifespanUserAction()));
|
||||
sb.append(" minutes.\n\n");
|
||||
sb.append("If you didn't create this account, just ignore this message.\n\n");
|
||||
sb.append("Thanks,\n");
|
||||
sb.append("The Keycloak Team");
|
||||
sb.append("If you didn't create this account, just ignore this message.\n");
|
||||
|
||||
addFooter(sb);
|
||||
|
||||
send(user.getEmail(), "Verify email", sb.toString());
|
||||
}
|
||||
|
@ -128,19 +129,44 @@ public class EmailSender {
|
|||
|
||||
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("If this was you, click on the link below to set a new password:\n");
|
||||
sb.append(uri.toString());
|
||||
sb.append("\n\nThis link will expire within ").append(TimeUnit.SECONDS.toMinutes(realm.getAccessCodeLifespanUserAction()));
|
||||
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("Thanks,\n");
|
||||
sb.append("The Keycloak Team");
|
||||
sb.append("If you don't want to reset your password, just ignore this message and nothing will be changed.\n");
|
||||
|
||||
addFooter(sb);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -276,6 +276,45 @@ public class RequiredActionsService {
|
|||
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) {
|
||||
String code = uriInfo.getQueryParameters().getFirst(FormFlows.CODE);
|
||||
if (code == null) {
|
||||
|
|
|
@ -176,6 +176,10 @@ public class FormFlows {
|
|||
return forwardToForm(Pages.LOGIN_RESET_PASSWORD);
|
||||
}
|
||||
|
||||
public Response forwardToUsernameReminder() {
|
||||
return forwardToForm(Pages.LOGIN_USERNAME_REMINDER);
|
||||
}
|
||||
|
||||
public Response forwardToLoginTotp() {
|
||||
return forwardToForm(Pages.LOGIN_TOTP);
|
||||
}
|
||||
|
|
|
@ -46,6 +46,8 @@ public class Pages {
|
|||
|
||||
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 ERROR = "error.ftl";
|
||||
|
|
|
@ -100,6 +100,14 @@ public class Urls {
|
|||
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) {
|
||||
return UriBuilder.fromUri(baseUri).path(RealmsResource.class);
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
|
@ -56,6 +56,9 @@ public class LoginPage extends AbstractPage {
|
|||
@FindBy(linkText = "Password")
|
||||
private WebElement resetPasswordLink;
|
||||
|
||||
@FindBy(linkText = "Username")
|
||||
private WebElement recoverUsernameLink;
|
||||
|
||||
@FindBy(id = "loginError")
|
||||
private WebElement loginErrorMessage;
|
||||
|
||||
|
@ -93,6 +96,11 @@ public class LoginPage extends AbstractPage {
|
|||
resetPasswordLink.click();
|
||||
}
|
||||
|
||||
public void recoverUsername() {
|
||||
recoverUsernameLink.click();
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void open() {
|
||||
oauth.openLoginForm();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in a new issue