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:
parent
3d22644bbe
commit
b0ffea699e
6 changed files with 118 additions and 61 deletions
|
@ -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";
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
Loading…
Reference in a new issue