KEYCLOAK-12186 Improve the OTP login form

-created and implemented login form design, where OTP device can be selected
-implemented selectable-card-view logic in jQuery
-edited related css and ftl theme resources
-fixed affected BrowserFlow tests

Signed-off-by: Peter Zaoral <pzaoral@redhat.com>
This commit is contained in:
Peter Zaoral 2020-02-05 10:06:17 +01:00 committed by Marek Posolda
parent 3d22644bbe
commit b0ffea699e
6 changed files with 118 additions and 61 deletions

View file

@ -73,4 +73,5 @@ public interface Details {
String X509_CERTIFICATE_ISSUER_DISTINGUISHED_NAME = "x509_cert_issuer_distinguished_name";
String CREDENTIAL_TYPE = "credential_type";
String SELECTED_CREDENTIAL_ID = "selected_credential_id";
}

View file

@ -27,6 +27,7 @@ import org.keycloak.authentication.requiredactions.UpdateTotp;
import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.OTPCredentialProvider;
import org.keycloak.credential.OTPCredentialProviderFactory;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.KeycloakSession;
@ -79,6 +80,8 @@ public class OTPFormAuthenticator extends AbstractUsernameFormAuthenticator impl
.getDefaultCredential(context.getSession(), context.getRealm(), context.getUser());
credentialId = defaultOtpCredential==null ? "" : defaultOtpCredential.getId();
}
context.getEvent().detail(Details.SELECTED_CREDENTIAL_ID, credentialId);
context.form().setAttribute(SELECTED_OTP_CREDENTIAL_ID, credentialId);
UserModel userModel = context.getUser();

View file

@ -20,11 +20,13 @@ import java.util.List;
import java.util.stream.Collectors;
import org.junit.Assert;
import org.keycloak.common.util.Retry;
import org.keycloak.testsuite.util.UIUtils;
import org.keycloak.testsuite.util.WaitUtils;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.ui.Select;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -43,9 +45,6 @@ public class LoginTotpPage extends LanguageComboboxAwarePage {
@FindBy(className = "alert-error")
private WebElement loginErrorMessage;
@FindBy(id = "selected-credential-id")
private WebElement selectedCredentialCombobox;
public void login(String totp) {
otpInput.clear();
if (totp != null) otpInput.sendKeys(totp);
@ -75,7 +74,7 @@ public class LoginTotpPage extends LanguageComboboxAwarePage {
// If false, we don't expect that credentials combobox is available. If true, we expect that it is available on the page
public void assertOtpCredentialSelectorAvailability(boolean expectedAvailability) {
try {
driver.findElement(By.id("selected-credential-id"));
driver.findElement(By.className("card-pf-view-single-select"));
Assert.assertTrue(expectedAvailability);
} catch (NoSuchElementException nse) {
Assert.assertFalse(expectedAvailability);
@ -84,29 +83,50 @@ public class LoginTotpPage extends LanguageComboboxAwarePage {
public List<String> getAvailableOtpCredentials() {
return new Select(selectedCredentialCombobox).getOptions()
.stream()
.map(WebElement::getText)
.collect(Collectors.toList());
return driver.findElements(getXPathForLookupAllCards())
.stream().map(WebElement::getText).collect(Collectors.toList());
}
public String getSelectedOtpCredential() {
return new Select(selectedCredentialCombobox).getOptions()
.stream()
.filter(webElement -> webElement.getAttribute("selected") != null)
.findFirst()
.orElseThrow(() -> {
try {
WebElement selected = driver.findElement(getXPathForLookupActiveCard());
return selected.getText();
} catch (NoSuchElementException nse) {
// No selected element found
return null;
}
}
return new AssertionError("Selected OTP credential not found");
private By getXPathForLookupAllCards() {
return By.xpath("//div[contains(@class, 'card-pf-view-single-select')]//h2");
}
})
.getText();
private By getXPathForLookupActiveCard() {
return By.xpath("//div[contains(@class, 'card-pf-view-single-select active')]//h2");
}
private By getXPathForLookupCardWithName(String credentialName) {
return By.xpath("//div[contains(@class, 'card-pf-view-single-select')]//h2[normalize-space() = '"+ credentialName +"']");
}
public void selectOtpCredential(String credentialName) {
new Select(selectedCredentialCombobox).selectByVisibleText(credentialName);
waitForElement(getXPathForLookupActiveCard());
WebElement webElement = driver.findElement(
getXPathForLookupCardWithName(credentialName));
UIUtils.clickLink(webElement);
}
}
// Workaround, but works with HtmlUnit (WaitUtils.waitForElement doesn't). Find better solution for the future...
private void waitForElement(By by) {
Retry.executeWithBackoff((currentCount) -> {
driver.findElement(by);
}, 10, 10);
}
}

View file

@ -1,47 +1,70 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout; section>
<#if section = "header">
${msg("doLogIn")}
<#elseif section = "form">
<form id="kc-otp-login-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<@layout.registrationLayout; section>
<#if section="header">
${msg("doLogIn")}
<#elseif section="form">
<form id="kc-otp-login-form" class="${properties.kcFormClass!}" action="${url.loginAction}"
method="post">
<#if otpLogin.userOtpCredentials?size gt 1>
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcInputWrapperClass!}">
<#list otpLogin.userOtpCredentials as otpCredential>
<div class="${properties.kcSelectOTPListClass!}">
<input type="hidden" value="${otpCredential.id}">
<div class="${properties.kcSelectOTPListItemClass!}">
<span class="${properties.kcAuthenticatorOtpCircleClass!}"></span>
<h2 class="${properties.kcSelectOTPItemHeadingClass!}">
${otpCredential.userLabel}
</h2>
</div>
</div>
</#list>
</div>
</div>
</#if>
<#if otpLogin.userOtpCredentials?size gt 1>
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="selected-credential-id" class="${properties.kcLabelClass!}">${msg("loginCredential")}</label>
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="otp" class="${properties.kcLabelClass!}">${msg("loginOtpOneTime")}</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input id="otp" name="otp" autocomplete="off" type="text" class="${properties.kcInputClass!}"
autofocus/>
</div>
</div>
<div class="${properties.kcInputWrapperClass!}">
<select id="selected-credential-id" name="selectedCredentialId" class="form-control" size="1">
<#list otpLogin.userOtpCredentials as otpCredential>
<option value="${otpCredential.id}" <#if otpCredential.id == otpLogin.selectedCredentialId>selected</#if>>${otpCredential.userLabel}</option>
</#list>
</select>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
<div class="${properties.kcFormOptionsWrapperClass!}">
</div>
</div>
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<input
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
name="login" id="kc-login" type="submit" value="${msg("doLogIn")}" />
</div>
</div>
</div>
</#if>
</form>
<script type="text/javascript" src="${url.resourcesPath}/node_modules/jquery/dist/jquery.min.js"></script>
<script type="text/javascript">
$(document).ready(function() {
// Card Single Select
$('.card-pf-view-single-select').click(function() {
if ($(this).hasClass('active'))
{ $(this).removeClass('active'); $(this).children().removeAttr('name'); }
else
{ $('.card-pf-view-single-select').removeClass('active');
$('.card-pf-view-single-select').children().removeAttr('name');
$(this).addClass('active'); $(this).children().attr('name', 'selectedCredentialId'); }
});
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="otp" class="${properties.kcLabelClass!}">${msg("loginOtpOneTime")}</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input id="otp" name="otp" autocomplete="off" type="text" class="${properties.kcInputClass!}"
autofocus/>
</div>
</div>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
<div class="${properties.kcFormOptionsWrapperClass!}">
</div>
</div>
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
</div>
</div>
</form>
</#if>
</@layout.registrationLayout>
var defaultCred = $('.card-pf-view-single-select')[0];
if (defaultCred) {
defaultCred.click();
}
});
</script>
</#if>
</@layout.registrationLayout>

View file

@ -511,6 +511,10 @@ a.zocial {
width: 100%;
}
.login-pf-page .card-pf{
margin-bottom: 10px;
}
#kc-form-login div.form-group:last-of-type,
#kc-register-form div.form-group:last-of-type,
#kc-update-profile-form div.form-group:last-of-type {

View file

@ -86,3 +86,9 @@ kcAuthenticatorPasswordClass=fa fa-unlock list-view-pf-icon-lg
kcAuthenticatorOTPClass=fa fa-mobile list-view-pf-icon-lg
kcAuthenticatorWebAuthnClass=fa fa-key list-view-pf-icon-lg
kcAuthenticatorWebAuthnPasswordlessClass=fa fa-key list-view-pf-icon-lg
##### css classes for the OTP Login Form
kcSelectOTPListClass=card-pf card-pf-view card-pf-view-select card-pf-view-single-select
kcSelectOTPListItemClass=card-pf-body card-pf-top-element
kcAuthenticatorOtpCircleClass=fa fa-mobile card-pf-icon-circle
kcSelectOTPItemHeadingClass=card-pf-title text-center